more fixes

- Drop the globe icon in the language switcher
- Only show RightArrow on nav menuitem hover
- Fix EU logo colors in light mode
This commit is contained in:
2026-09-23 22:33:58 +02:00
parent 2663d7eac6
commit c0daacf1ba
15 changed files with 43 additions and 372 deletions
@@ -1,273 +0,0 @@
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}
/>
------------------------------------------------------------------------ */
@@ -1,35 +0,0 @@
/* ============================================================================
LANGUAGE SWITCHER - figma `globe` 478:16381 (navigation 20:236)
----------------------------------------------------------------------------
The navigation now carries the language control as a bare 24x24 globe in the
Actions slot, replacing the "AR" / "EN" pill that used to sit next to the EU
badge. The exported asset fills its path with a hard-coded #1D1D1B, which
would be invisible on the dark surface, so it is applied as a mask over
`currentColor` - the same technique as `.sh-arrow-icon` and `.sh-card-icon`.
That keeps the exact exported geometry while the colour comes from whichever
semantic token the bar has in force.
GEOMETRY, straight from Figma: a 24x24 box (`--control-icon-size`) with the
glyph filling it edge to edge. The globe is radially symmetric, so unlike the
arrow it needs no RTL mirroring.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-language-globe {
display: block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/globe-solid.svg");
mask-image: url("/assets/icons/globe-solid.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
}
@@ -53,7 +53,7 @@ export interface LanguageSwitcherProps {
* same page under that locale, preserving the rest of the path.
*
* The mobile drawer uses this, where a spelled-out target is clearer than an
* icon. The desktop bar uses <LanguageMenu />, the globe with a dropdown.
* icon.
*/
export function LanguageSwitcher({
locale,
@@ -1,6 +1,5 @@
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
getCounterpartLocale,
getCounterpartPath,
@@ -34,15 +33,11 @@ export interface LanguageToggleProps {
}
/**
* Both languages, side by side: the globe, the current locale in an accent
* chip, and the other locale as a link to the same page under that locale -
* the redesign's nav control, replacing the globe-plus-dropdown on the desktop
* bar.
* Both languages, side by side: the current locale in an accent chip, and the
* other locale as a link to the same page under that locale.
*
* One tap to switch and no state to open, which is why the draft prefers it;
* the drawer still uses <LanguageSwitcher /> and <LanguageMenu /> remains for
* anywhere a list of more than two languages is needed.
*
* the drawer still uses <LanguageSwitcher />.
* The current language is marked with `aria-current`, not by colour alone. The
* link carries its own lang/hrefLang/dir and is named with the target
* language's endonym ("العربية"), so a screen reader switches voice for it
@@ -71,8 +66,6 @@ export function LanguageToggle({
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
<span className="flex items-center">
<span
aria-current="true"
@@ -3,8 +3,6 @@ export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export { LanguageMenu } from "./LanguageMenu";
export type { LanguageMenuProps } from "./LanguageMenu";
export { LanguageToggle } from "./LanguageToggle";
export type { LanguageToggleProps } from "./LanguageToggle";