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
@@ -89,7 +89,6 @@ const CARD_ICON_SRC: Record<CardIconName, string> = {
"arrow-up": "url('/assets/icons/arrow-up.svg')",
linkedin: "url('/assets/icons/linkedin.svg')",
mail: "url('/assets/icons/mail.svg')",
globe: "url('/assets/icons/globe.svg')",
};
export interface CardIconProps {
+2 -5
View File
@@ -18,7 +18,6 @@
socials={[
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
{ kind: "website", href: "https://example.org", label: "Website" },
]}
/>
</ul>
@@ -30,14 +29,14 @@
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
export type CardTeamSocialKind = "linkedin" | "mail";
export interface CardTeamSocial {
kind: CardTeamSocialKind;
href: string;
/**
* Accessible name of the link. Defaults to the platform name; pass a
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
* translated string on Arabic pages ("البريد الإلكتروني").
*/
label?: string;
}
@@ -59,13 +58,11 @@ export interface CardTeamProps {
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
linkedin: "linkedin",
mail: "mail",
website: "globe",
};
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
linkedin: "LinkedIn",
mail: "Email",
website: "Website",
};
export function CardTeam({
+2 -3
View File
@@ -33,10 +33,9 @@ export interface CardLinkTarget {
rel?: string;
}
/** The five icons the card family uses, each an asset exported from Figma. */
/** The icons the card family uses, each an asset exported from Figma. */
export type CardIconName =
| "arrow-right"
| "arrow-up"
| "linkedin"
| "mail"
| "globe";
| "mail";
@@ -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";
@@ -51,8 +51,14 @@ function MenuArrow() {
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
/* Mirrors with the page: the arrow points at the reading edge. */
className="shrink-0 rtl:-scale-x-100"
/* Mirrors with the page: the arrow points at the reading edge. It only
appears when the reader hovers or focus-es the link, so a row of
always-visible arrows does not announce every entry at once. */
className={cx(
"shrink-0 rtl:-scale-x-100 opacity-0",
"transition-opacity duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
"group-hover:opacity-100 group-focus-visible:opacity-100",
)}
>
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
</svg>
@@ -489,7 +495,7 @@ export function DesktopNav({
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"body-sm-bold flex items-center justify-between gap-md",
"group body-sm-bold flex items-center justify-between gap-md",
"rounded-control px-sm py-xs no-underline",
"text-[var(--text-primary)]",
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
@@ -45,16 +45,17 @@ export interface NavigationProps {
* The SHARP-ER site navigation - Figma frame 20:236.
*
* Three slots, spread across the bar: the wordmark at the inline start, the
* mega-menu groups in the middle, and the language globe at the inline end.
* mega-menu groups in the middle, and the language control at the inline end.
* Everything is laid out with logical properties, so the Arabic bar mirrors
* (logo right, globe left) from `<html dir="rtl">` alone - there is no separate
* Arabic component, which is what variants 334:526 and 334:764 show.
* (logo right, language control left) from `<html dir="rtl">` alone - there is
* no separate Arabic component, which is what variants 334:526 and 334:764
* show.
*
* Below the xl breakpoint the bar collapses to wordmark + hamburger (20:220)
* and the whole tree moves into a full-screen drawer.
*
* EU VISIBILITY: the updated design has no funding badge in the bar - the
* Actions slot holds the language globe and the theme toggle. The mandatory
* Actions slot holds the language control and the theme toggle. The mandatory
* "Funded by the European Union" lockup is carried by <Footer />, which
* renders on every page, and by the mobile drawer; it is not dropped from the
* site.
@@ -122,16 +123,16 @@ export function Navigation({
/>
<div className="flex items-center gap-md">
{/* The globe is desktop-only: the mobile bar (20:220) is wordmark +
hamburger, and the drawer carries the switcher instead. Visibility
lives on this wrapper rather than on the child, because two
display utilities on one element are resolved by the order
{/* The language control is desktop-only: the mobile bar (20:220) is
wordmark + hamburger, and the drawer carries the switcher instead.
Visibility lives on this wrapper rather than on the child, because
two display utilities on one element are resolved by the order
Tailwind emits them, not by class-attribute order. */}
{showLanguageSwitcher && (
/* Figma 555:3848 sets a 20px rule between the links and the
actions, then 24px to the globe. It is decorative, so it is
hidden from assistive technology and drawn with a border token
rather than a rule element. */
actions, then 24px to the language cluster. It is decorative, so
it is hidden from assistive technology and drawn with a border
token rather than a rule element. */
<div className="hidden items-center gap-lg xl:flex">
<span
aria-hidden="true"
@@ -41,7 +41,7 @@ export interface NavigationStrings {
closeMenu: string;
/** Accessible name of the logo link. */
homeLinkLabel: string;
/** Accessible name of the language globe, which carries no text of its own. */
/** Accessible name of the in-bar language control, which carries no text of its own. */
languageMenuLabel: string;
/** The mandatory EU funding statement. */
euStatement: string;
+1 -1
View File
@@ -9,7 +9,7 @@
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
key roving: there is one tab stop per question, which is what a keyboard
user expects from a list of links-like controls, and it matches the
disclosure the navigation already ships (DesktopNav, LanguageMenu).
disclosure the navigation already ships (DesktopNav, the language switcher).
The answer is CMS richtext (markdown), so it renders through <RichText />
rather than as a raw string.
+12 -6
View File
@@ -43,7 +43,7 @@
is why every inline edge below is logical (ps-/pe-/start-/end-).
========================================================================= */
import { Link } from "react-router";
import { Link, useRouteLoaderData } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
@@ -61,6 +61,7 @@ import {
buildWebSiteJsonLd,
} from "~/lib/seo";
import { safeHref } from "~/lib/safe-href";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
import type { Route } from "./+types/home";
import { Accordion } from "./_page-kit/accordion";
@@ -434,6 +435,10 @@ export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, approach, gallery, partners, news, faqs } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).home;
/* The hero badge sits on the page's own surface, so it follows the theme
exactly as the footer's badge does: same root cookie, same emblem. */
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
/* The three pillars are the About page's approved copy - one source, so the
two pages can never drift apart. */
const pillars = pageCopy(locale).about.pillars;
@@ -495,16 +500,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
]}
reveal
/* EU visibility sits at the top of the hero (the draft's badge). The
emblem is the dark-surface export because the hero sits on the
page's own dark surface. `emblemAlt` is left at its empty default:
the statement is real text beside the emblem, so naming the image
would read the attribution out twice. */
emblem matches the page surface the hero stands on, which the theme
cookie decides - dark emblem in dark mode, light emblem in light
mode, like the footer's badge. `emblemAlt` is left at its empty
default: the statement is real text beside the emblem, so naming the
image would read the attribution out twice. */
funderBadge={
<EuBadge
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
surface={theme}
className="self-start"
/>
}