このコミットが含まれているのは:
2026-10-12 15:43:32 +09:00
コミット 7c25dedac2
7個のファイルの変更、343行の追加、35行の削除
+100 -21
ファイルの表示
@@ -20,6 +20,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
@@ -40,6 +41,9 @@ export const menuOutline = (
const materialFlg = /^\/materials\/\d+/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
const helpIdentifier = pathName.split ('/')[2] ?? ''
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
@@ -84,7 +88,9 @@ export const menuOutline = (
visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' }] },
{ name: '全体履歴', to: '/help/changes' },
{ component: <Separator/>, visible: helpVsbl },
{ name: '履歴', to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
@@ -107,6 +113,8 @@ const TopNav: FC<Props> = ({ user }) => {
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 measure = (idx: number) => {
const nav = navRef.current
@@ -131,6 +139,8 @@ const TopNav: FC<Props> = ({ user }) => {
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [desktop, setDesktop] = useState (false)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
@@ -197,15 +207,81 @@ const TopNav: FC<Props> = ({ user }) => {
const dir = dirRef.current
useLayoutEffect (() => {
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
const layout = layoutRef.current
const bar = barRef.current
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
if (layout == null || bar == null || primary == null)
return
addEventListener ('resize', onResize)
return () => {
cancelAnimationFrame (raf)
removeEventListener ('resize', onResize)
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
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 required = primary.getBoundingClientRect ().width
+ (userLink?.getBoundingClientRect ().width ?? 0)
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (userLink == null ? 0 : parseFloat (style.columnGap))
if (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)
if (userLink != null)
observer?.observe (userLink)
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])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -218,20 +294,22 @@ const TopNav: FC<Props> = ({ user }) => {
}, [activeIdx, location])
return (
<>
<nav className="top-nav-root px-3 flex flex-wrap justify-between items-center w-full">
<div className="flex shrink-0 items-center gap-2 h-12">
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop} data-density={density}>
<nav ref={barRef}
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 mx-2 text-base
xl:mx-4 xl:text-xl font-bold"
className="top-nav-brand-link shrink-0 whitespace-nowrap font-bold"
onClick={() => {
scroll (0, 0)
}}>
ぼざクリ タグ広場
</PrefetchLink>
<div ref={navRef} className="relative hidden xl:flex shrink-0 h-12 items-center">
<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',
@@ -257,7 +335,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</PrefetchLink>
@@ -272,7 +350,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true)
measure (-1)
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
その他 &raquo;
@@ -284,7 +362,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button
type="button"
className="top-nav-mobile-toggle xl:hidden shrink-0 whitespace-nowrap
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
@@ -298,7 +376,8 @@ const TopNav: FC<Props> = ({ user }) => {
key="submenu-shell"
{...(animationsOff ? { }
: { layout: true })}
className="relative z-20 hidden xl:block shrink-0 overflow-hidden top-nav-submenu"
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
@@ -442,7 +521,7 @@ const TopNav: FC<Props> = ({ user }) => {
? (
menuOpen && (
<div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col xl:hidden',
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
@@ -499,7 +578,7 @@ const TopNav: FC<Props> = ({ user }) => {
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col xl:hidden',
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
@@ -574,7 +653,7 @@ const TopNav: FC<Props> = ({ user }) => {
<Separator/>
</motion.div>)}
</AnimatePresence>)}
</>)
</div>)
}
export default TopNav