ファイル
btrc-hub/frontend/src/components/TopNav.tsx
T
2026-10-18 12:18:52 +09:00

800 行
28 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれています
このファイルには、他の文字と見間違える可能性があるUnicode文字が含まれています。 それが意図的なものと考えられる場合は、この警告を無視して構いません。 それらの文字を表示するにはエスケープボタンを使用します。
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isLocaleCode,
localeFromPath,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
import { useUITranslation } from '@/lib/useUITranslation'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null; locales: AvailableLocale[] }
type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
{ tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null
pathName: string },
): Menu => {
const pathName = unlocalisedPath (fullPathname)
const locale = localeFromPath (fullPathname) ?? 'ja'
const message = (
key: Parameters<typeof uiMessage>[1],
values?: Record<string, string | number>,
) => ({ name: uiMessage (locale, key, values),
nameLanguage: uiMessageLanguage (locale, key) })
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
const materialFlg = /^\/materials\/\d+/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
const helpIdentifier = pathName.split ('/')[2] ?? ''
return [
{ ...message ('posts'), to: '/posts', subMenu: [
{ ...message ('list'), to: '/posts' },
{ ...message ('search'), to: '/posts/search' },
{ ...message ('add'), to: '/posts/new', visible: editable },
{ ...message ('allHistory'), to: '/posts/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:広場' }] },
{ ...message ('tags'), to: '/tags', subMenu: [
{ ...message ('master'), to: '/tags' },
{ ...message ('nicoLinks'), to: '/tags/nico' },
{ ...message ('allHistory'), to: '/tags/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
visible: tagFlg },
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ ...message ('materials'), to: '/materials', visible: true, subMenu: [
{ ...message ('list'), to: '/materials' },
{ ...message ('add'), to: '/materials/new', visible: editable },
{ ...message ('suppressions'), to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ ...message ('allHistory'), to: '/materials/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
visible: materialFlg && Boolean (material?.tag) },
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ ...message ('wiki'), to: '/wiki', subMenu: [
{ ...message ('search'), to: '/wiki' },
{ ...message ('create'), to: '/wiki/new', visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' },
{ component: <Separator/>, visible: helpVsbl },
{ ...message ('history'),
to: `/help/changes?identifier=${ helpIdentifier }`,
visible: helpVsbl }] },
{ ...message ('entertainment'), visible: false, subMenu: [
{ ...message ('theatre'), to: '/theatres/1' },
{ ...message ('gekanator'), to: '/gekanator' }] },
{ ...message ('users'), to: '/users/settings', visible: false, subMenu: [
{ ...message ('list'), to: '/users', visible: false },
{ ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false },
{ ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] },
{ ...message ('legal'), visible: false, subMenu: [
{ ...message ('terms'), to: '/tos' }] }]
}
const TopNav: FC<Props> = ({ user, locales }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null)
const layoutRef = useRef<HTMLDivElement | null> (null)
const barRef = useRef<HTMLElement | null> (null)
const submenuContentRef = useRef<HTMLDivElement | null> (null)
const highlightTargetRef = useRef (-1)
const measure = (idx: number) => {
const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
if (nav == null || el == null || nav.offsetWidth === 0
|| layoutRef.current?.dataset.desktop !== 'true')
{
setHL (current => current.visible
? { left: 0, width: 0, visible: false } : current)
return
}
// Layout offsets exclude Framer Motion's temporary projection transforms.
let left = 0
let node: HTMLElement | null = el
while (node != null && node !== nav)
{
left += node.offsetLeft
const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null
}
const width = node === nav ? el.offsetWidth : 0
const visible = width > 0
setHL (current =>
(current.left === left && current.width === width && current.visible === visible)
? current : { left, width, visible })
}
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0,
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
const [desktop, setDesktop] = useState (false)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
const pathname = unlocalisedPath (location.pathname)
const tagFlg = /^\/tags\/\d+/.test (pathname)
const materialFlg = /^\/materials\/\d+/.test (pathname)
const effectiveTitle = (((tagFlg || materialFlg)
? pathname.split ('/')[2]
: wikiPage?.title)
?? '')
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
const { data: material } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
highlightTargetRef.current = moreVsbl ? -1 : activeIdx
const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }`
const topNavTransition =
animationsOff
? { duration: 0 }
: (reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const })
const opacityTransition =
animationsOff
? { duration: 0 }
: (reducedAnimations
? { duration: .08 }
: { duration: .12 })
const highlightTransitionClass =
animationsOff
? undefined
: (reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out')
const prevActiveIdxRef = useRef<number> (activeIdx)
if (activeIdx !== prevActiveIdxRef.current)
{
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
prevActiveIdxRef.current = activeIdx
}
const dir = dirRef.current
useLayoutEffect (() => {
const layout = layoutRef.current
const bar = barRef.current
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
if (layout == null || bar == null || primary == null)
return
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
let raf = 0
let active = true
const update = () => {
if (!(window.matchMedia ('(min-width: 768px)').matches))
{
setDesktop (false)
setDensity ('auto')
return
}
// Measure the same desktop elements while the mobile layout is active.
layout.setAttribute ('data-measuring', '')
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
const fittingDensity = (() => {
try
{
for (const candidate of ['auto', 'comfortable', 'compact'] as const)
{
layout.setAttribute ('data-density', candidate)
const style = getComputedStyle (bar)
const primaryWidth = Math.max (
primary.getBoundingClientRect ().width, primary.scrollWidth)
const accountWidth =
account == null
? 0
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth)
const required = primaryWidth + accountWidth
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (account == null ? 0 : parseFloat (style.columnGap))
if (Math.ceil (required) <= bar.clientWidth)
return candidate
}
return null
}
finally
{
layout.setAttribute ('data-density', previousDensity)
layout.removeAttribute ('data-measuring')
}
}) ()
setDensity (fittingDensity ?? 'compact')
setDesktop (fittingDensity != null)
if (fittingDensity != null)
measure (moreVsbl ? -1 : activeIdx)
}
const schedule = () => {
if (!(active))
return
cancelAnimationFrame (raf)
raf = requestAnimationFrame (update)
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
observer?.observe (bar)
observer?.observe (primary)
for (const item of itemsRef.current)
if (item != null)
observer?.observe (item)
if (account != null)
observer?.observe (account)
update ()
addEventListener ('resize', schedule)
document.fonts?.addEventListener ('loadingdone', schedule)
void document.fonts?.ready.then (schedule)
return () => {
active = false
cancelAnimationFrame (raf)
observer?.disconnect ()
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
useLayoutEffect (() => {
measure (moreVsbl ? -1 : activeIdx)
if (!(desktop))
setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
}, [])
useLayoutEffect (() => {
const content = submenuContentRef.current
if (content == null)
return
const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
observer?.observe (content)
update ()
addEventListener ('resize', update)
return () => {
observer?.disconnect ()
removeEventListener ('resize', update)
}
}, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => {
setMenuOpen (false)
setOpenItemIdx (activeIdx)
}, [activeIdx, location])
const localeSelector = (
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
rounded px-1 focus-within:ring-2">
<Globe aria-hidden="true" size={16}/>
<span className="top-nav-locale-name whitespace-nowrap text-sm">
<LocalisedText language={locale}>{localeName}</LocalisedText>
</span>
<select
aria-label={formatUIString (localeName ?? '', locale)}
value={locale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={event => {
const nextLocale = event.target.value
if (isLocaleCode (nextLocale)
&& locales.some (entry => entry.code === nextLocale))
{
navigate (switchLocalePath (location, nextLocale))
}
}}>
{locales.map (entry => isLocaleCode (entry.code) && (
<option key={entry.code} value={entry.code}>
{formatUIString (entry.name, entry.code)}
</option>))}
</select>
</label>)
return (
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop} data-density={density}>
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12">
<PrefetchLink
to="/posts"
className="top-nav-brand-link shrink-0 whitespace-nowrap font-bold"
onClick={() => {
scroll (0, 0)
}}>
{label ('labels.brand')}
</PrefetchLink>
<div ref={navRef}
className="top-nav-desktop-menu relative shrink-0 h-12 items-center">
<div aria-hidden
className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight',
highlightTransitionClass)}
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
className="shrink-0 whitespace-nowrap"
{...(animationsOff
? { }
: { layoutId: `menu-${ menu.indexOf (item) }` })}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
to={item.to}
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
(i === openItemIdx) && 'font-bold')}>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
onClick={() => setMoreVsbl (false)}
onMouseEnter={() => {
setMoreVsbl (true)
measure (-1)
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
<TranslatedText messageKey="more"/>
</PrefetchLink>
</div>
</div>
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
<TopNavUser user={user}/>
{localeSelector}
</div>
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
{menuOpen && localeSelector}
<button
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
border-0 bg-transparent pr-4"
aria-label={menuToggleLabel.text}
lang={menuToggleLabel.language}
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : label ('navigation:menuToggle')}
</button>
</div>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
{...(animationsOff ? { }
: { layout: true })}
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: topNavTransition }}
onAnimationComplete={() => {
measure (highlightTargetRef.current)
}}
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl
? (
moreMenu.map ((item, i) => (
<div key={i} className="contents">
<motion.div
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</h2>
</motion.div>
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<motion.div
key={`c-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx] === item)
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx] === item)
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</div>
</div>)))
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative min-h-[40px]">
{animationsOff
? (
<div className="flex min-h-[40px] flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`} className="min-w-0 max-w-full">
{item.component}
</div>)
: (
<div key={`l-${ i }`} className="min-w-0 max-w-full">
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir} mode="popLayout">
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={topNavTransition}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`${ activeSubmenuId }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`${ activeSubmenuId }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</div>
</motion.div>
</AnimatePresence>
{animationsOff
? (
menuOpen && (
<div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
{i === openItemIdx && (
<div className="top-nav-submenu w-full">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</div>)}
</Fragment>))}
<PrefetchLink
to="/more"
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</div>))
: (
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="top-nav-submenu w-full"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
<LocalisedText
language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>)}
</div>)
}
export default TopNav