diff --git a/client/src/components/NavDropdown.jsx b/client/src/components/NavDropdown.jsx new file mode 100644 index 0000000..8ea42f7 --- /dev/null +++ b/client/src/components/NavDropdown.jsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from 'react' +import { NavLink, useLocation } from 'react-router-dom' + +// One dropdown section in the public header — a menu an admin created from +// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10). +// +// It **opens on click, never on hover**. Hover menus are unusable on touch, and +// the alternative (make the trigger a link too) means tapping to open navigates +// away instead. A section is a container, not a destination, so the trigger has +// no `to` at all. +// +// Everything else here is the keyboard and dismissal contract a menu needs: +// Escape closes and returns focus to the trigger, an outside press closes, +// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` + +// `aria-expanded` are what let a screen reader announce it as a menu rather than +// as a button that mysteriously changes the page. +export default function NavDropdown({ label, items, linkStyle }) { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + const triggerRef = useRef(null) + const location = useLocation() + + // The trigger shows the active treatment when the page you are on lives in + // this menu — otherwise entering a section makes the header look like nothing + // is selected. + const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to))) + + // Close on navigation. The menu is rendered inside a sticky header that + // survives route changes, so nothing else would dismiss it. + useEffect(() => setOpen(false), [location.pathname]) + + useEffect(() => { + if (!open) return undefined + const onKey = (e) => { + if (e.key !== 'Escape') return + setOpen(false) + triggerRef.current?.focus() + } + // `mousedown`, not `click`: closing on the press means a press that lands on + // another trigger opens that one in the same gesture. + const onOutside = (e) => { + if (!wrapRef.current?.contains(e.target)) setOpen(false) + } + document.addEventListener('keydown', onKey) + document.addEventListener('mousedown', onOutside) + return () => { + document.removeEventListener('keydown', onKey) + document.removeEventListener('mousedown', onOutside) + } + }, [open]) + + // Roving focus with the arrow keys, wrapping at both ends. + const onMenuKeyDown = (e) => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return + e.preventDefault() + const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])] + if (links.length === 0) return + const at = links.indexOf(document.activeElement) + const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length + links[at === -1 ? 0 : next].focus() + } + + return ( +
+ + + {open && ( +
+ {items.map((item) => ( + setOpen(false)} + className="sans" + style={({ isActive }) => ({ + padding: '7px 10px', + borderRadius: 'var(--radius-input)', + fontSize: '0.85rem', + textDecoration: 'none', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + ...linkStyle({ isActive }), + ...(isActive ? {} : { color: 'var(--muted)' }), + })} + > + {item.label} + + ))} +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84340d9..e9e142d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -5,7 +5,8 @@ import BrandLogo from './BrandLogo.jsx' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' -import { applyNavOverrides } from '../lib/navOverrides.js' +import NavDropdown from './NavDropdown.jsx' +import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' import { parseJsonSetting } from '../lib/settingsJson.js' // One consistent top nav for the whole public site. Every page gets the same @@ -50,16 +51,19 @@ export default function SiteHeader() { const shardFeatures = useShardFeatures() // An admin may relabel, reorder and hide these entries from Admin → - // Navigation (THEMING_AND_NAV.md §7). Two things about the order here: + // Navigation, and may group them into dropdown sections alongside links of + // their own (THEMING_AND_NAV.md §7). Two things about the order here: // // • the override merge runs FIRST and the feature filter after it, so the // filter stays the boundary — an override cannot un-hide a shard surface - // this viewer may not see, whatever it says; - // • with no stored row, applyNavOverrides returns NAV itself, so an + // this viewer may not see, whatever it says. `pruneNav` applies the same + // check inside a section and drops one it leaves empty, so a dropdown + // never opens onto nothing; + // • with no stored row this is the coded NAV, in code order, so an // untouched instance renders exactly what it renders today. const nav = useMemo(() => { - const merged = applyNavOverrides(NAV, parseJsonSetting(settings.nav_public)) - return merged.filter((item) => !item.feature || canSee(shardFeatures, item.feature)) + const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public)) + return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) }, [settings.nav_public, shardFeatures]) // Where the auth entry points: staff → admin, player → portal, else sign in. @@ -93,11 +97,15 @@ export default function SiteHeader() { {siteTitle}