Files
sharper.ly/apps/web/app/components/ui/Navigation/MobileNav.tsx
T

347 lines
11 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { Link } from "react-router";
import { EuBadge } from "../EuBadge";
2026-09-11 02:46:56 +02:00
import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher";
import { Brand } from "./Brand";
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",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
2026-09-11 02:46:56 +02:00
<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>
</>
);
}