800 行
28 KiB
TypeScript
800 行
28 KiB
TypeScript
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
|