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"
/>
}
@@ -1,10 +0,0 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="globe" clip-path="url(#clip0_0_123)">
<path id="Vector" d="M12 0C9.62663 0 7.30655 0.703788 5.33316 2.02236C3.35977 3.34094 1.8217 5.21509 0.913451 7.4078C0.00519943 9.60051 -0.232441 12.0133 0.230582 14.3411C0.693605 16.6689 1.83649 18.8071 3.51472 20.4853C5.19295 22.1635 7.33115 23.3064 9.65892 23.7694C11.9867 24.2324 14.3995 23.9948 16.5922 23.0866C18.7849 22.1783 20.6591 20.6402 21.9776 18.6668C23.2962 16.6935 24 14.3734 24 12C23.9966 8.81846 22.7312 5.76821 20.4815 3.51852C18.2318 1.26883 15.1815 0.00344108 12 0ZM15.222 7H8.77801C9.62677 5.32427 10.7119 3.77916 12 2.412C13.2886 3.77877 14.3738 5.32396 15.222 7ZM16.022 9C16.3294 9.97068 16.4898 10.9819 16.498 12C16.4898 13.0181 16.3294 14.0293 16.022 15H7.97601C7.66866 14.0293 7.50821 13.0181 7.50001 12C7.50821 10.9819 7.66866 9.97068 7.97601 9H16.022ZM9.40001 2.356C8.24766 3.76578 7.29659 5.3287 6.57401 7H3.35301C4.67886 4.71643 6.85166 3.04775 9.40001 2.356ZM2.00001 12C1.99991 10.9824 2.15538 9.97067 2.46101 9H5.90001C5.64076 9.97915 5.50636 10.9871 5.50001 12C5.50636 13.0129 5.64076 14.0209 5.90001 15H2.46101C2.15538 14.0293 1.99991 13.0176 2.00001 12ZM3.35301 17H6.57401C7.29659 18.6713 8.24766 20.2342 9.40001 21.644C6.85166 20.9522 4.67886 19.2836 3.35301 17ZM8.77801 17H15.222C14.3732 18.6757 13.2882 20.2208 12 21.588C10.7114 20.2212 9.62625 18.676 8.77801 17ZM14.605 21.644C15.7556 20.2339 16.705 18.671 17.426 17H20.647C19.3223 19.2825 17.1515 20.9511 14.605 21.644ZM22 12C22.0001 13.0176 21.8446 14.0293 21.539 15H18.1C18.3592 14.0209 18.4936 13.0129 18.5 12C18.4936 10.9871 18.3592 9.97915 18.1 9H21.537C21.8433 9.97056 21.9995 10.9823 22 12ZM17.426 7C16.705 5.32899 15.7556 3.76609 14.605 2.356C17.1515 3.04893 19.3223 4.71747 20.647 7H17.426Z" fill="#1D1D1B"/>
</g>
<defs>
<clipPath id="clip0_0_123">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="globe" clip-path="url(#clip0_0_6)">
<path id="Vector" d="M12.8338 7C12.8338 10.2219 10.2219 12.8338 7 12.8338M12.8338 7C12.8338 3.77808 10.2219 1.1662 7 1.1662M12.8338 7H1.1662M7 12.8338C3.77808 12.8338 1.1662 10.2219 1.1662 7M7 12.8338C5.50202 11.2609 4.66648 9.17208 4.66648 7C4.66648 4.82793 5.50202 2.73908 7 1.1662M7 12.8338C8.49798 11.2609 9.33352 9.17208 9.33352 7C9.33352 4.82793 8.49798 2.73908 7 1.1662M7 1.1662C3.77808 1.1662 1.1662 3.77808 1.1662 7" stroke="#008764" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_0_6">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 786 B