diff --git a/src/components/nav-about-menu.tsx b/src/components/nav-about-menu.tsx new file mode 100644 index 00000000..384cabed --- /dev/null +++ b/src/components/nav-about-menu.tsx @@ -0,0 +1,58 @@ +import { aboutNodes, aboutOverviewHref } from '../data/nav-menu'; + +const isActive = (pathname: string, href: string) => + pathname === href || pathname.startsWith(href + '/'); + +const ArrowIcon = () => ( + +); + +type Props = { + pathname: string; + variant: 'panel' | 'accordion'; + onNavigate?: () => void; +}; + +export default function NavAboutMenu({ pathname, variant, onNavigate }: Props) { + const pad = variant === 'panel' ? 'py-2' : 'py-3'; + const rowBase = `group flex items-center gap-3 rounded-xl px-2.5 ${pad} transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent`; + const rowState = (active: boolean) => + active ? 'bg-accent/10' : 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'; + + return ( +
+

Our nodes

+ {aboutNodes.map((node) => { + const active = isActive(pathname, node.href); + return ( + + + ); + })} +
+ + About ELIXIR Norway — overview + + +
+
+ ); +} diff --git a/src/components/nav-dropdown.tsx b/src/components/nav-dropdown.tsx new file mode 100644 index 00000000..a7b042ad --- /dev/null +++ b/src/components/nav-dropdown.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; + +const ChevronIcon = ({ className }: { className?: string }) => ( + +); + +const linkColor = (active: boolean) => + active ? 'text-accent' : 'text-brand-grey dark:text-gray-300 hover:text-brand-primary dark:hover:text-white'; + +type Props = { + /** Visible text; also the trigger link. */ + label: string; + /** Destination of the label link. The panel is opened by the chevron, not the label. */ + href: string; + /** Whether `href` is the current page. */ + active: boolean; + /** Id tying the chevron (aria-controls) to the panel region. */ + panelId: string; + /** Accessible name for the panel region. */ + panelLabel: string; + /** Extra classes for the floating panel (e.g. width). */ + panelClassName?: string; + /** Ref registrar from useMagicPill so the glider can measure this item. */ + rootRef?: (el: HTMLElement | null) => void; + /** Pointer-enter hook so the magic pill can glide to this item. */ + onHover?: () => void; + /** Panel content; receives a `close` callback to dismiss on navigate. */ + children: (close: () => void) => ReactNode; +}; + +/** + * A top-level nav item that is both a link (label → href) and a disclosure (a + * separate chevron button toggles a floating panel). Click/keyboard is the + * canonical path; hover-intent open is a progressive enhancement on pointer-fine + * devices. Closes on Escape (restoring focus to the chevron) and outside click. + * Composes with useMagicPill via `rootRef` + `onHover`. + */ +export default function NavDropdown({ + label, href, active, panelId, panelLabel, panelClassName = '', rootRef, onHover, children, +}: Props) { + const [open, setOpen] = useState(false); + const [hoverCapable, setHoverCapable] = useState(false); + const shouldReduceMotion = useReducedMotion(); + const wrapRef = useRef(null); + const chevronRef = useRef(null); + const closeTimer = useRef | null>(null); + + const setRoot = (el: HTMLDivElement | null) => { wrapRef.current = el; rootRef?.(el); }; + const close = () => setOpen(false); + const clearCloseTimer = () => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }; + const scheduleClose = () => { + clearCloseTimer(); + closeTimer.current = setTimeout(() => setOpen(false), 150); + }; + + useEffect(() => { + setHoverCapable(window.matchMedia('(hover: hover) and (pointer: fine)').matches); + }, []); + + useEffect(() => () => { if (closeTimer.current) clearTimeout(closeTimer.current); }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { setOpen(false); chevronRef.current?.focus(); } + }; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onDown); }; + }, [open]); + + return ( +
{ + onHover?.(); + if (hoverCapable) { clearCloseTimer(); setOpen(true); } + }} + onMouseLeave={() => { if (hoverCapable) scheduleClose(); }} + > + + {label} + + + + + {open && ( + { if (hoverCapable) scheduleClose(); }} + > + {children(close)} + + )} + +
+ ); +} diff --git a/src/components/nav-mobile-accordion.tsx b/src/components/nav-mobile-accordion.tsx new file mode 100644 index 00000000..e487c54c --- /dev/null +++ b/src/components/nav-mobile-accordion.tsx @@ -0,0 +1,69 @@ +import { useState, type ReactNode } from 'react'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; + +const ChevronIcon = ({ className }: { className?: string }) => ( + +); + +type Props = { + /** Visible text; also the trigger link. */ + label: string; + /** Destination of the label link. The panel is expanded by the chevron, not the label. */ + href: string; + /** Whether `href` is the current page. */ + active: boolean; + /** Id tying the chevron (aria-controls) to the collapsible panel. */ + panelId: string; + /** Class for the big mobile link, shared with sibling nav links. */ + linkClassName: string; + /** Called when the label or any child link is activated (closes the overlay). */ + onNavigate: () => void; + /** Collapsible content. */ + children: ReactNode; +}; + +/** + * A mobile nav row that is both a link (label → href) and a disclosure: a + * separate chevron button expands an inline collapsible panel beneath it. + * Mirrors NavDropdown's link-plus-chevron pattern for the full-screen overlay. + */ +export default function NavMobileAccordion({ label, href, active, panelId, linkClassName, onNavigate, children }: Props) { + const [open, setOpen] = useState(false); + const shouldReduceMotion = useReducedMotion(); + + return ( + <> +
+ + {label} + + +
+ + {open && ( + +
{children}
+
+ )} +
+ + ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 9d613194..430ce037 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,7 +1,11 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' -import React, { Fragment, useEffect, useState, useCallback } from "react"; +import { Fragment, useEffect, useState, useCallback } from "react"; import CommandPalette from "./command-palette.tsx"; import ThemeToggle from "./theme-toggle.tsx"; +import NavAboutMenu from "./nav-about-menu.tsx"; +import NavDropdown from "./nav-dropdown.tsx"; +import NavMobileAccordion from "./nav-mobile-accordion.tsx"; +import { useMagicPill } from "../lib/hooks/use-magic-pill"; const SearchIcon = ({ className }: { className?: string }) => (
ELIXIR Norway - ELIXIR Norway logo - ELIXIR Norway logo + ELIXIR Norway logo + ELIXIR Norway logo
- {/* Desktop nav links */} -
- {navigation.map((item) => { - const isActive = pathname === item.href || pathname.startsWith(item.href + '/'); + {/* Desktop nav links + magic pill */} +
setHoveredIndex(null)} + > + {glider && ( +