Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/components/nav-about-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { aboutNodes, aboutOverviewHref } from '../data/nav-menu';

const isActive = (pathname: string, href: string) =>
pathname === href || pathname.startsWith(href + '/');

const ArrowIcon = () => (
<svg className="h-3.5 w-3.5 shrink-0 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
);

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 (
<div>
<p className="px-2.5 pb-2 pt-1 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Our nodes</p>
{aboutNodes.map((node) => {
const active = isActive(pathname, node.href);
return (
<a
key={node.href}
href={node.href}
onClick={onNavigate}
aria-current={active ? 'page' : undefined}
className={`${rowBase} ${rowState(active)}`}
>
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: node.color }} aria-hidden="true" />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-accent">{node.nodeName}</span>
<span className="block truncate text-xs text-gray-600 dark:text-gray-400">{node.universityShort}</span>
</span>
</a>
);
})}
<div className="mt-1 border-t border-gray-200/70 pt-1.5 dark:border-gray-700/40">
<a
href={aboutOverviewHref}
onClick={onNavigate}
aria-current={pathname === aboutOverviewHref ? 'page' : undefined}
className={`group flex items-center gap-2 rounded-xl px-2.5 ${pad} text-sm font-semibold text-accent transition-colors hover:bg-accent/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
>
About ELIXIR Norway — overview
<ArrowIcon />
</a>
</div>
</div>
);
}
129 changes: 129 additions & 0 deletions src/components/nav-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';

const ChevronIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m6 9 6 6 6-6" />
</svg>
);

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<HTMLDivElement | null>(null);
const chevronRef = useRef<HTMLButtonElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={setRoot}
className="relative z-10 flex items-center"
onMouseEnter={() => {
onHover?.();
if (hoverCapable) { clearCloseTimer(); setOpen(true); }
}}
onMouseLeave={() => { if (hoverCapable) scheduleClose(); }}
>
<a
href={href}
className={`rounded-lg py-2 pl-3 pr-1.5 text-sm 2xl:text-base font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${linkColor(active)}`}
aria-current={active ? 'page' : undefined}
>
{label}
</a>
<button
ref={chevronRef}
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? `Close ${label} menu` : `Open ${label} menu`}
className={`rounded-lg py-2 pl-1 pr-2.5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${linkColor(active)}`}
>
<ChevronIcon className={`h-3.5 w-3.5 transition-transform ${shouldReduceMotion ? '' : 'duration-200'} ${open ? 'rotate-180' : ''}`} />
</button>

<AnimatePresence>
{open && (
<motion.div
id={panelId}
role="region"
aria-label={panelLabel}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className={`absolute left-0 top-full mt-3 rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/95 dark:bg-dark-background/95 backdrop-blur-xl p-3 shadow-xl shadow-black/[0.12] dark:shadow-black/40 ${panelClassName}`}
onMouseEnter={clearCloseTimer}
onMouseLeave={() => { if (hoverCapable) scheduleClose(); }}
>
{children(close)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
69 changes: 69 additions & 0 deletions src/components/nav-mobile-accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState, type ReactNode } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';

const ChevronIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m6 9 6 6 6-6" />
</svg>
);

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 (
<>
<div className="flex items-center justify-between gap-2">
<a href={href} onClick={onNavigate} className={linkClassName} aria-current={active ? 'page' : undefined}>
{label}
</a>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? `Collapse ${label} section` : `Expand ${label} section`}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl text-brand-primary dark:text-white hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ChevronIcon className={`h-5 w-5 transition-transform ${shouldReduceMotion ? '' : 'duration-200'} ${open ? 'rotate-180' : ''}`} />
</button>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={panelId}
initial={shouldReduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={shouldReduceMotion ? { opacity: 1 } : { height: 'auto', opacity: 1 }}
exit={shouldReduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="py-2 pl-1">{children}</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
Loading
Loading