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:
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user