274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
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<HTMLDivElement>(null);
|
||
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
|
|
const pendingFocus = useRef<OptionPosition | null>(null);
|
||
|
|
|
||
|
|
const rawId = useId();
|
||
|
|
const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||
|
|
|
||
|
|
const options = useCallback(
|
||
|
|
(): HTMLElement[] =>
|
||
|
|
Array.from(
|
||
|
|
rootRef.current?.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>) => {
|
||
|
|
if (event.key === "ArrowDown") {
|
||
|
|
event.preventDefault();
|
||
|
|
openAndFocus("first");
|
||
|
|
} else if (event.key === "ArrowUp") {
|
||
|
|
event.preventDefault();
|
||
|
|
openAndFocus("last");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOptionKeyDown = (event: ReactKeyboardEvent<HTMLAnchorElement>) => {
|
||
|
|
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<HTMLDivElement>) => {
|
||
|
|
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<HTMLDivElement>) => {
|
||
|
|
const next = event.relatedTarget as Node | null;
|
||
|
|
if (!next || !rootRef.current?.contains(next)) close();
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={rootRef}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
onBlur={handleBlur}
|
||
|
|
className={["relative", className].filter(Boolean).join(" ")}
|
||
|
|
>
|
||
|
|
<button
|
||
|
|
ref={buttonRef}
|
||
|
|
type="button"
|
||
|
|
aria-expanded={open}
|
||
|
|
aria-controls={listId}
|
||
|
|
aria-label={label}
|
||
|
|
title={label}
|
||
|
|
onClick={() => (open ? close() : setOpen(true))}
|
||
|
|
onKeyDown={handleButtonKeyDown}
|
||
|
|
className={[
|
||
|
|
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
|
||
|
|
"text-[var(--text-primary)] hover:text-[var(--text-accent)]",
|
||
|
|
"aria-expanded:text-[var(--text-accent)]",
|
||
|
|
TRANSITION,
|
||
|
|
FOCUS_RING,
|
||
|
|
].join(" ")}
|
||
|
|
>
|
||
|
|
<span aria-hidden="true" className="sh-language-globe" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<ul
|
||
|
|
id={listId}
|
||
|
|
hidden={!open}
|
||
|
|
className={[
|
||
|
|
"absolute end-0 top-full z-[var(--layer-dropdown)] mt-sm",
|
||
|
|
"flex min-w-[10rem] flex-col gap-2xs p-2xs",
|
||
|
|
"rounded-control border border-[var(--border-subtle)]",
|
||
|
|
"bg-[var(--surface-raised)] shadow-[var(--elevation-popover)]",
|
||
|
|
].join(" ")}
|
||
|
|
>
|
||
|
|
{LOCALES.map((option) => {
|
||
|
|
const current = option === locale;
|
||
|
|
return (
|
||
|
|
<li key={option}>
|
||
|
|
<Link
|
||
|
|
to={buildLocalizedHref(pathname, option, { search, hash })}
|
||
|
|
reloadDocument={reloadDocument}
|
||
|
|
hrefLang={option}
|
||
|
|
lang={option}
|
||
|
|
aria-current={current ? "true" : undefined}
|
||
|
|
data-language-option={option}
|
||
|
|
onClick={close}
|
||
|
|
onKeyDown={handleOptionKeyDown}
|
||
|
|
className={[
|
||
|
|
current ? "body-md-bold" : "body-md",
|
||
|
|
"block rounded-control px-sm py-xs text-start no-underline",
|
||
|
|
"text-[var(--text-primary)] hover:bg-[var(--surface-muted)]",
|
||
|
|
"aria-[current=true]:text-[var(--text-accent)]",
|
||
|
|
TRANSITION,
|
||
|
|
FOCUS_RING,
|
||
|
|
].join(" ")}
|
||
|
|
>
|
||
|
|
{/* The label carries its own direction; the row does not, so
|
||
|
|
both names line up on the page's inline-start edge instead
|
||
|
|
of each hugging the side its own script reads from. */}
|
||
|
|
<span dir={getDirection(option)}>{LOCALE_ENDONYM[option]}</span>
|
||
|
|
</Link>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default LanguageMenu;
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
USAGE
|
||
|
|
---------------------------------------------------------------------------
|
||
|
|
// In the navigation bar, wired from the router's location:
|
||
|
|
const { pathname, search, hash } = useLocation();
|
||
|
|
<LanguageMenu
|
||
|
|
label={strings.languageMenuLabel}
|
||
|
|
locale={locale}
|
||
|
|
pathname={pathname}
|
||
|
|
search={search}
|
||
|
|
hash={hash}
|
||
|
|
/>
|
||
|
|
------------------------------------------------------------------------ */
|