345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import {
|
|||
|
|
useCallback,
|
||
|
|
useEffect,
|
||
|
|
useId,
|
||
|
|
useRef,
|
||
|
|
useState,
|
||
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
||
|
|
} from "react";
|
||
|
|
import { Link } from "react-router";
|
||
|
|
|
||
|
|
import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher";
|
||
|
|
|
||
|
|
import { Brand } from "./Brand";
|
||
|
|
import { EuBadge } from "./EuBadge";
|
||
|
|
import { MenuBars } from "./MenuBars";
|
||
|
|
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
|
||
|
|
import { isNavGroup, type NavItem, type NavigationStrings, type NavSurface } from "./types";
|
||
|
|
|
||
|
|
/** Must stay in step with the `xl:` variants below (Tailwind xl = 80rem). */
|
||
|
|
const DESKTOP_MEDIA_QUERY = "(min-width: 80rem)";
|
||
|
|
|
||
|
|
const FOCUSABLE_SELECTOR = [
|
||
|
|
"a[href]",
|
||
|
|
"button:not([disabled])",
|
||
|
|
"input:not([disabled])",
|
||
|
|
"select:not([disabled])",
|
||
|
|
"textarea:not([disabled])",
|
||
|
|
'[tabindex]:not([tabindex="-1"])',
|
||
|
|
].join(", ");
|
||
|
|
|
||
|
|
function focusablesIn(root: HTMLElement): HTMLElement[] {
|
||
|
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||
|
|
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MobileNavProps {
|
||
|
|
items: readonly NavItem[];
|
||
|
|
pathname: string;
|
||
|
|
search?: string;
|
||
|
|
hash?: string;
|
||
|
|
locale: Locale;
|
||
|
|
strings: NavigationStrings;
|
||
|
|
surface: NavSurface;
|
||
|
|
/** Locale-prefixed home path for the logo inside the drawer. */
|
||
|
|
homeHref: string;
|
||
|
|
showLanguageSwitcher: boolean;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hamburger trigger plus the full-screen drawer it opens.
|
||
|
|
*
|
||
|
|
* While the drawer is open, focus is trapped inside it, everything behind it is
|
||
|
|
* `inert` (so assistive technology cannot browse the page under an aria-modal
|
||
|
|
* dialog either), the page behind cannot scroll, Escape closes it, and focus
|
||
|
|
* returns to the hamburger on close.
|
||
|
|
*/
|
||
|
|
export function MobileNav({
|
||
|
|
items,
|
||
|
|
pathname,
|
||
|
|
search,
|
||
|
|
hash,
|
||
|
|
locale,
|
||
|
|
strings,
|
||
|
|
surface,
|
||
|
|
homeHref,
|
||
|
|
showLanguageSwitcher,
|
||
|
|
className,
|
||
|
|
}: MobileNavProps) {
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
|
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||
|
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const wasOpen = useRef(false);
|
||
|
|
|
||
|
|
const rawId = useId();
|
||
|
|
const baseId = `sharper-drawer-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||
|
|
const drawerId = `${baseId}-panel`;
|
||
|
|
const headingId = (groupId: string) => `${baseId}-${groupId}-heading`;
|
||
|
|
|
||
|
|
const close = useCallback(() => setOpen(false), []);
|
||
|
|
|
||
|
|
/* Focus in on open, and back to the hamburger on close. */
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
wasOpen.current = true;
|
||
|
|
closeRef.current?.focus();
|
||
|
|
} else if (wasOpen.current) {
|
||
|
|
wasOpen.current = false;
|
||
|
|
triggerRef.current?.focus();
|
||
|
|
}
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
/* The drawer is aria-modal, so the rest of the page has to be inert as well -
|
||
|
|
otherwise a screen reader still browses the header, main and footer behind
|
||
|
|
it. Rather than naming those landmarks (the drawer is itself rendered
|
||
|
|
inside the header, so inerting the header would inert the drawer), every
|
||
|
|
ancestor up to <body> has its OTHER children inerted. `inert` is set as an
|
||
|
|
attribute so no DOM-lib typing is needed, and an element that was already
|
||
|
|
inert is skipped so the restore never clears someone else's flag.
|
||
|
|
|
||
|
|
This runs after the focus effect above on purpose: focus is already inside
|
||
|
|
the drawer by the time the element it came from goes inert. */
|
||
|
|
useEffect(() => {
|
||
|
|
const drawer = drawerRef.current;
|
||
|
|
if (!open || !drawer) return;
|
||
|
|
|
||
|
|
const inerted: Element[] = [];
|
||
|
|
let node: Element | null = drawer;
|
||
|
|
|
||
|
|
while (node !== null) {
|
||
|
|
const parent: HTMLElement | null = node.parentElement;
|
||
|
|
if (parent === null) break;
|
||
|
|
|
||
|
|
const siblings: Element[] = Array.from(parent.children);
|
||
|
|
for (const sibling of siblings) {
|
||
|
|
if (sibling === node || sibling.hasAttribute("inert")) continue;
|
||
|
|
sibling.setAttribute("inert", "");
|
||
|
|
inerted.push(sibling);
|
||
|
|
}
|
||
|
|
|
||
|
|
node = parent === document.body ? null : parent;
|
||
|
|
}
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
for (const element of inerted) element.removeAttribute("inert");
|
||
|
|
};
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
/* Body scroll lock. The scrollbar's width is handed back as inline-end
|
||
|
|
padding so the page behind does not jump sideways when it disappears. */
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
const { body, documentElement } = document;
|
||
|
|
const previousOverflow = body.style.overflow;
|
||
|
|
const previousPadding = body.style.paddingInlineEnd;
|
||
|
|
const scrollbar = window.innerWidth - documentElement.clientWidth;
|
||
|
|
|
||
|
|
body.style.overflow = "hidden";
|
||
|
|
if (scrollbar > 0) body.style.paddingInlineEnd = `${scrollbar}px`;
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
body.style.overflow = previousOverflow;
|
||
|
|
body.style.paddingInlineEnd = previousPadding;
|
||
|
|
};
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
/* Keeps focus inside the drawer even when something moves it programmatically
|
||
|
|
(the Tab handler below covers ordinary keyboard travel). */
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
const handleFocusIn = (event: FocusEvent) => {
|
||
|
|
const drawer = drawerRef.current;
|
||
|
|
if (drawer && !drawer.contains(event.target as Node)) {
|
||
|
|
focusablesIn(drawer)[0]?.focus();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
document.addEventListener("focusin", handleFocusIn);
|
||
|
|
return () => document.removeEventListener("focusin", handleFocusIn);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
/* The drawer is desktop-hidden; close it rather than stranding a locked body
|
||
|
|
if the viewport grows past the breakpoint while it is open. */
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open || typeof window.matchMedia !== "function") return;
|
||
|
|
const query = window.matchMedia(DESKTOP_MEDIA_QUERY);
|
||
|
|
if (query.matches) {
|
||
|
|
setOpen(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const handleChange = (event: MediaQueryListEvent) => {
|
||
|
|
if (event.matches) setOpen(false);
|
||
|
|
};
|
||
|
|
query.addEventListener("change", handleChange);
|
||
|
|
return () => query.removeEventListener("change", handleChange);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
const handleDrawerKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||
|
|
if (event.key === "Escape") {
|
||
|
|
event.preventDefault();
|
||
|
|
close();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (event.key !== "Tab") return;
|
||
|
|
|
||
|
|
const drawer = drawerRef.current;
|
||
|
|
if (!drawer) return;
|
||
|
|
|
||
|
|
const stops = focusablesIn(drawer);
|
||
|
|
if (stops.length === 0) {
|
||
|
|
event.preventDefault();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const first = stops[0];
|
||
|
|
const last = stops[stops.length - 1];
|
||
|
|
const active = document.activeElement;
|
||
|
|
const inside = drawer.contains(active);
|
||
|
|
|
||
|
|
if (event.shiftKey && (active === first || !inside)) {
|
||
|
|
event.preventDefault();
|
||
|
|
last?.focus();
|
||
|
|
} else if (!event.shiftKey && (active === last || !inside)) {
|
||
|
|
event.preventDefault();
|
||
|
|
first?.focus();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const linkClasses = cx(
|
||
|
|
"block rounded-control py-2xs no-underline",
|
||
|
|
"hover:text-[var(--text-accent)]",
|
||
|
|
"aria-[current=page]:text-[var(--text-accent)]",
|
||
|
|
HOVER_TRANSITION,
|
||
|
|
FOCUS_RING,
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<button
|
||
|
|
ref={triggerRef}
|
||
|
|
type="button"
|
||
|
|
aria-expanded={open}
|
||
|
|
aria-controls={drawerId}
|
||
|
|
aria-label={open ? strings.closeMenu : strings.openMenu}
|
||
|
|
onClick={() => setOpen((value) => !value)}
|
||
|
|
className={cx(
|
||
|
|
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
|
||
|
|
FOCUS_RING,
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<MenuBars />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div
|
||
|
|
id={drawerId}
|
||
|
|
ref={drawerRef}
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-label={strings.primaryNavLabel}
|
||
|
|
hidden={!open}
|
||
|
|
data-surface={surface}
|
||
|
|
onKeyDown={handleDrawerKeyDown}
|
||
|
|
className={cx(
|
||
|
|
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
|
||
|
|
"bg-[var(--surface-page)] xl:hidden",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex min-h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
|
||
|
|
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
|
||
|
|
<button
|
||
|
|
ref={closeRef}
|
||
|
|
type="button"
|
||
|
|
aria-label={strings.closeMenu}
|
||
|
|
onClick={close}
|
||
|
|
className={cx(
|
||
|
|
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
|
||
|
|
FOCUS_RING,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<MenuBars open />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<nav
|
||
|
|
aria-label={strings.primaryNavLabel}
|
||
|
|
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
|
||
|
|
>
|
||
|
|
<ul className="flex flex-col gap-xl">
|
||
|
|
{items.map((item) =>
|
||
|
|
isNavGroup(item) ? (
|
||
|
|
<li key={item.id}>
|
||
|
|
<h2
|
||
|
|
id={headingId(item.id)}
|
||
|
|
className="body-md-bold text-[var(--text-primary)]"
|
||
|
|
>
|
||
|
|
{item.label}
|
||
|
|
</h2>
|
||
|
|
<ul
|
||
|
|
aria-labelledby={headingId(item.id)}
|
||
|
|
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
|
||
|
|
>
|
||
|
|
{item.children.map((child) => (
|
||
|
|
<li key={child.id}>
|
||
|
|
<Link
|
||
|
|
to={child.href}
|
||
|
|
onClick={close}
|
||
|
|
aria-current={
|
||
|
|
isCurrentPath(child.href, pathname) ? "page" : undefined
|
||
|
|
}
|
||
|
|
className={cx(
|
||
|
|
linkClasses,
|
||
|
|
"body-md text-[var(--text-secondary)]",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{child.label}
|
||
|
|
</Link>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</li>
|
||
|
|
) : (
|
||
|
|
<li key={item.id}>
|
||
|
|
<Link
|
||
|
|
to={item.href}
|
||
|
|
onClick={close}
|
||
|
|
aria-current={
|
||
|
|
isCurrentPath(item.href, pathname) ? "page" : undefined
|
||
|
|
}
|
||
|
|
className={cx(
|
||
|
|
linkClasses,
|
||
|
|
"body-md-bold text-[var(--text-primary)]",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{item.label}
|
||
|
|
</Link>
|
||
|
|
</li>
|
||
|
|
),
|
||
|
|
)}
|
||
|
|
</ul>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
|
||
|
|
<EuBadge
|
||
|
|
statement={strings.euStatement}
|
||
|
|
emblemAlt={strings.euEmblemAlt}
|
||
|
|
locale={locale}
|
||
|
|
surface={surface}
|
||
|
|
/>
|
||
|
|
{showLanguageSwitcher && (
|
||
|
|
<LanguageSwitcher
|
||
|
|
locale={locale}
|
||
|
|
pathname={pathname}
|
||
|
|
search={search}
|
||
|
|
hash={hash}
|
||
|
|
size="md"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|