import { useCallback, useEffect, useId, useRef, useState, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Link } from "react-router"; import "./LanguageSwitcher.css"; import { buildLocalizedHref, getDirection, LOCALE_ENDONYM, LOCALES, type Locale, } from "./locale-path"; /* Reinforces the global :focus-visible rule in app.css with the same tier-2 focus tokens, so the ring is visible even if a caller overrides `outline`. */ const FOCUS_RING = "focus-visible:outline-[color:var(--focus-ring-color)] " + "focus-visible:outline-[length:var(--focus-ring-width)] " + "focus-visible:outline-offset-[var(--focus-ring-offset)]"; const TRANSITION = "transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]"; type OptionPosition = "first" | "last"; export interface LanguageMenuProps { /** The locale the reader is currently on. */ locale: Locale; /** Current pathname, e.g. "/en/news-and-updates". */ pathname: string; /** Query string to carry across the switch. Usually `location.search`. */ search?: string; /** Fragment to carry across the switch. Usually `location.hash`. */ hash?: string; /** * Accessible name of the globe, in the reader's language ("Language"). It is * required because the globe carries no text of its own. */ label: string; /** Navigate with a full document request instead of a client transition. */ reloadDocument?: boolean; className?: string; } /** * The navigation's language control: the globe (figma 478:16381) as a button * that opens a short list of the site's languages, each linking to the page the * reader is on under that locale, with the query string and fragment kept. * * Not drawn in Figma - the design shows the globe alone - so the list is built * from the tier-2 surface, border and elevation tokens a popover uses, and * hangs from the globe's inline-end edge so it opens inward in both directions. * * A disclosure rather than an ARIA menu: the entries are ordinary links and are * announced and followed as links. Each carries its own lang/hrefLang and its * label its own dir, so a screen reader switches voice for "العربية". The * current language is marked with aria-current and set in bold, so the state is * not carried by colour alone. ArrowDown/ArrowUp open the list and move through it, Escape closes it * and returns focus to the globe, and an outside press or focus leaving closes * it. Focus is never trapped. */ export function LanguageMenu({ locale, pathname, search, hash, label, reloadDocument = false, className, }: LanguageMenuProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const buttonRef = useRef(null); const pendingFocus = useRef(null); const rawId = useId(); const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const options = useCallback( (): HTMLElement[] => Array.from( rootRef.current?.querySelectorAll("[data-language-option]") ?? [], ), [], ); const focusOption = useCallback( (position: OptionPosition) => { const all = options(); (position === "first" ? all[0] : all[all.length - 1])?.focus(); }, [options], ); const close = useCallback(() => { pendingFocus.current = null; setOpen(false); }, []); /* Outside press. pointerdown rather than click so the list is already gone by the time the click lands on whatever is underneath. */ useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); /* A keyboard request to open and step into the list can only be honoured once the list has rendered. */ useEffect(() => { const position = pendingFocus.current; if (!open || position === null) return; pendingFocus.current = null; focusOption(position); }, [open, focusOption]); const openAndFocus = (position: OptionPosition) => { if (open) { focusOption(position); return; } pendingFocus.current = position; setOpen(true); }; const handleButtonKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); openAndFocus("first"); } else if (event.key === "ArrowUp") { event.preventDefault(); openAndFocus("last"); } }; const handleOptionKeyDown = (event: ReactKeyboardEvent) => { const all = options(); const index = all.indexOf(event.currentTarget); const moveTo = (to: number) => all[(to + all.length) % all.length]?.focus(); switch (event.key) { case "ArrowDown": event.preventDefault(); moveTo(index + 1); break; case "ArrowUp": event.preventDefault(); moveTo(index - 1); break; case "Home": event.preventDefault(); moveTo(0); break; case "End": event.preventDefault(); moveTo(all.length - 1); break; default: break; } }; const handleKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== "Escape" || !open) return; event.preventDefault(); close(); buttonRef.current?.focus(); }; /* React's onBlur is focusout, so it fires for descendants too. */ const handleBlur = (event: ReactFocusEvent) => { const next = event.relatedTarget as Node | null; if (!next || !rootRef.current?.contains(next)) close(); }; return (
); } export default LanguageMenu; /* --------------------------------------------------------------------------- USAGE --------------------------------------------------------------------------- // In the navigation bar, wired from the router's location: const { pathname, search, hash } = useLocation(); ------------------------------------------------------------------------ */