Files
sharper.ly/apps/web/app/components/ui/Navigation/MobileNav.tsx
T
Illusion 2ff5cf47cf more changes
- Restore the SHARP-ER in the homepage hero section
- Match the design for the 3-image section in the homepage
2026-09-23 23:36:36 +02:00

384 lines
13 KiB
TypeScript

import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { EuBadge } from "../EuBadge";
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(", ");
/**
* The hamburger and its close twin. The glyph is the drafts' 24px box, which is
* well under the 44x44 WCAG 2.5.5 target size, so the control is a 44px square
* around it. The negative inline-end margin gives back the 10px of padding that
* would otherwise push the glyph off the page gutter the wordmark opposite it
* lines up with - logical, so it mirrors in Arabic.
*/
const TAP_TARGET = cx(
"-me-[0.625rem] inline-flex size-11 shrink-0 cursor-pointer",
"items-center justify-center rounded-control",
);
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.
*
* THE DRAWER IS PORTALLED TO <body>, AND HAS TO BE.
* The bar it hangs off paints the drafts' translucent ground with
* `backdrop-filter: blur(14px)`, and a filtered element becomes the containing
* block for every `position: fixed` descendant (CSS Filter Effects 1 s.6.1).
* Rendered in place, `fixed inset-0` therefore resolved against the 64px bar
* rather than the viewport: the panel painted a 64px strip and its menu spilled
* over the page with no background behind it. The header also carries
* `z-index: var(--layer-header)`, which is a stacking context, so an
* `aria-modal` dialog nested inside it could never rise above anything painted
* above the bar either. A portal fixes both at once, and it is where a modal
* dialog belongs regardless.
*
* The portal is client-only, so the panel joins the DOM on mount. Nothing is
* lost: the trigger is a button with an onClick, so without JavaScript the
* drawer could not be opened anyway, and every link in it is already in the
* server-rendered DesktopNav markup.
*/
export function MobileNav({
items,
pathname,
search,
hash,
locale,
strings,
surface,
homeHref,
showLanguageSwitcher,
className,
}: MobileNavProps) {
const [open, setOpen] = useState(false);
/* The portal target only exists in a browser, and the first client render has
to match the server's, so the panel is mounted one commit later. */
const [mounted, setMounted] = 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), []);
useEffect(() => setMounted(true), []);
/* 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, every ancestor up to <body> has its
OTHER children inerted; from the portal that is simply body's other
children, which is exactly the header, main and footer. `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,
);
const panel = (
<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)]">
<Brand
href={homeHref}
label={strings.homeLinkLabel}
surface={surface}
animate={!isCurrentPath(homeHref, pathname)}
/>
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(TAP_TARGET, 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>
);
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(TAP_TARGET, "xl:hidden", FOCUS_RING, className)}
>
<MenuBars />
</button>
{mounted ? createPortal(panel, document.body) : null}
</>
);
}