initial commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
getCounterpartLocale,
|
||||
getCounterpartPath,
|
||||
getDirection,
|
||||
LOCALE_ENDONYM,
|
||||
LOCALE_SHORT_LABEL,
|
||||
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)]";
|
||||
|
||||
/** Padding presets. Kept as a prop rather than an overridable class, because
|
||||
two padding utilities on one element are resolved by the order Tailwind
|
||||
emits them in, not by the order they appear in the class attribute. */
|
||||
const SIZE_CLASSES = {
|
||||
sm: "px-sm py-2xs",
|
||||
md: "min-h-[44px] px-md py-xs",
|
||||
} as const;
|
||||
|
||||
export type LanguageSwitcherSize = keyof typeof SIZE_CLASSES;
|
||||
|
||||
export interface LanguageSwitcherProps {
|
||||
/** 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;
|
||||
/**
|
||||
* Navigate with a full document request instead of a client transition.
|
||||
* Off by default: the root route re-renders <html lang>/<html dir> on a
|
||||
* client navigation, so a soft transition already lands on a correct
|
||||
* document. Turn it on if a page needs the server to re-render first.
|
||||
*/
|
||||
reloadDocument?: boolean;
|
||||
/** "sm" for the desktop bar, "md" for a 44px touch target in the drawer. */
|
||||
size?: LanguageSwitcherSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact language toggle: shows the two-letter code of the OTHER language
|
||||
* ("AR" while reading English, "EN" while reading Arabic) and links to the
|
||||
* same page under that locale, preserving the rest of the path.
|
||||
*
|
||||
* The Figma navigation has no language switcher - a gap recorded in
|
||||
* docs/SHARP-ER_Website Recommendations.docx - so this is designed to sit in
|
||||
* the existing bar: label-md on a pill outline, matching the EU badge height.
|
||||
*/
|
||||
export function LanguageSwitcher({
|
||||
locale,
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
reloadDocument = false,
|
||||
size = "sm",
|
||||
className = "",
|
||||
}: LanguageSwitcherProps) {
|
||||
const target = getCounterpartLocale(locale);
|
||||
const href = getCounterpartPath(pathname, locale, { search, hash });
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
reloadDocument={reloadDocument}
|
||||
hrefLang={target}
|
||||
lang={target}
|
||||
dir={getDirection(target)}
|
||||
aria-label={LOCALE_ENDONYM[target]}
|
||||
title={LOCALE_ENDONYM[target]}
|
||||
data-language-switcher={target}
|
||||
className={[
|
||||
"label-md inline-flex shrink-0 items-center justify-center rounded-pill",
|
||||
"border border-[var(--border-default)]",
|
||||
SIZE_CLASSES[size],
|
||||
"text-[var(--text-primary)] no-underline",
|
||||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||||
"ease-[var(--motion-ease-standard)]",
|
||||
"hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]",
|
||||
FOCUS_RING,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<span aria-hidden="true">{LOCALE_SHORT_LABEL[target]}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
USAGE
|
||||
---------------------------------------------------------------------------
|
||||
// Reading English at /en/news-and-updates?page=2 -> links to
|
||||
// /ar/news-and-updates?page=2 and renders the pill "AR".
|
||||
<LanguageSwitcher locale="en" pathname="/en/news-and-updates" search="?page=2" />
|
||||
|
||||
// Reading Arabic -> renders "EN", links to /en/...
|
||||
<LanguageSwitcher locale="ar" pathname="/ar/contact" />
|
||||
|
||||
// Inside the mobile drawer, where a 44px touch target is wanted:
|
||||
<LanguageSwitcher locale={locale} pathname={pathname} size="md" />
|
||||
|
||||
// Force a document navigation (server re-renders <html lang>/<html dir>):
|
||||
<LanguageSwitcher locale={locale} pathname={pathname} reloadDocument />
|
||||
|
||||
// Wired from a route, using the router's own location:
|
||||
const { pathname, search, hash } = useLocation();
|
||||
<LanguageSwitcher locale={locale} pathname={pathname} search={search} hash={hash} />
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -0,0 +1,26 @@
|
||||
export { LanguageSwitcher, default } from "./LanguageSwitcher";
|
||||
export type {
|
||||
LanguageSwitcherProps,
|
||||
LanguageSwitcherSize,
|
||||
} from "./LanguageSwitcher";
|
||||
|
||||
export {
|
||||
buildLocalizedHref,
|
||||
DEFAULT_LOCALE,
|
||||
getCounterpartLocale,
|
||||
getCounterpartPath,
|
||||
getDirection,
|
||||
getLocaleFromPathname,
|
||||
isCurrentPath,
|
||||
isLocale,
|
||||
LOCALE_ENDONYM,
|
||||
LOCALE_SHORT_LABEL,
|
||||
LOCALES,
|
||||
localizePath,
|
||||
stripLocale,
|
||||
} from "./locale-path";
|
||||
export type {
|
||||
CounterpartPathOptions,
|
||||
Direction,
|
||||
Locale,
|
||||
} from "./locale-path";
|
||||
@@ -0,0 +1,140 @@
|
||||
/* ============================================================================
|
||||
LOCALE + PATH HELPERS
|
||||
----------------------------------------------------------------------------
|
||||
SHARP-ER is served with an EXPLICIT locale prefix on both languages:
|
||||
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language;
|
||||
there is no ?lang= query parameter anywhere in the product.
|
||||
|
||||
These are pure functions on purpose - they run identically during SSR and in
|
||||
the browser, and they are the single source of truth for "what is the other
|
||||
language's URL for the page I am on".
|
||||
========================================================================= */
|
||||
|
||||
export const LOCALES = ["en", "ar"] as const;
|
||||
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export type Direction = "ltr" | "rtl";
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
|
||||
/** Locale -> the value that belongs in <html dir>. */
|
||||
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
|
||||
en: "ltr",
|
||||
ar: "rtl",
|
||||
};
|
||||
|
||||
/** Locale -> its endonym, used as the accessible name of the switcher link. */
|
||||
export const LOCALE_ENDONYM: Record<Locale, string> = {
|
||||
en: "English",
|
||||
ar: "العربية",
|
||||
};
|
||||
|
||||
/** Locale -> the two-letter badge the switcher shows. */
|
||||
export const LOCALE_SHORT_LABEL: Record<Locale, string> = {
|
||||
en: "EN",
|
||||
ar: "AR",
|
||||
};
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return (
|
||||
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDirection(locale: Locale): Direction {
|
||||
return DIRECTION_BY_LOCALE[locale];
|
||||
}
|
||||
|
||||
/** The language a reader on `locale` can switch to. Two locales, so it flips. */
|
||||
export function getCounterpartLocale(locale: Locale): Locale {
|
||||
return locale === "en" ? "ar" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale prefix of a pathname, or null when there is none.
|
||||
* "/ar/contact" -> "ar" "/about" -> null "/" -> null
|
||||
*/
|
||||
export function getLocaleFromPathname(pathname: string): Locale | null {
|
||||
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
||||
return isLocale(first) ? first : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pathname with its locale prefix removed, always leading-slashed.
|
||||
* "/ar/news/2026" -> "/news/2026" "/en" -> "/" "/news" -> "/news"
|
||||
*/
|
||||
export function stripLocale(pathname: string): string {
|
||||
const locale = getLocaleFromPathname(pathname);
|
||||
const withoutLeadingSlashes = pathname.replace(/^\/+/, "");
|
||||
const rest = locale
|
||||
? withoutLeadingSlashes.slice(locale.length)
|
||||
: withoutLeadingSlashes;
|
||||
|
||||
if (rest === "" || rest === "/") return "/";
|
||||
return rest.startsWith("/") ? rest : `/${rest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same page under `locale`. Safe to call with an already-prefixed path, an
|
||||
* unprefixed path, or a bare "/" - the locale prefix is normalised either way.
|
||||
* ("/en/contact", "ar") -> "/ar/contact"
|
||||
* ("/contact", "ar") -> "/ar/contact"
|
||||
* ("/", "en") -> "/en"
|
||||
*/
|
||||
export function localizePath(pathname: string, locale: Locale): string {
|
||||
const rest = stripLocale(pathname);
|
||||
return rest === "/" ? `/${locale}` : `/${locale}${rest}`;
|
||||
}
|
||||
|
||||
export interface CounterpartPathOptions {
|
||||
/** Query string to carry over, with or without the leading "?". */
|
||||
search?: string;
|
||||
/** Fragment to carry over, with or without the leading "#". */
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart-language URL for the current location: same page, other
|
||||
* locale, query string and fragment preserved.
|
||||
*/
|
||||
export function getCounterpartPath(
|
||||
pathname: string,
|
||||
locale: Locale,
|
||||
options: CounterpartPathOptions = {},
|
||||
): string {
|
||||
return buildLocalizedHref(
|
||||
pathname,
|
||||
getCounterpartLocale(locale),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** localizePath() plus the preserved search/hash. */
|
||||
export function buildLocalizedHref(
|
||||
pathname: string,
|
||||
locale: Locale,
|
||||
{ search = "", hash = "" }: CounterpartPathOptions = {},
|
||||
): string {
|
||||
const normalisedSearch =
|
||||
search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : "";
|
||||
const normalisedHash =
|
||||
hash && hash !== "#" ? (hash.startsWith("#") ? hash : `#${hash}`) : "";
|
||||
|
||||
return `${localizePath(pathname, locale)}${normalisedSearch}${normalisedHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `href` addresses the page currently at `pathname`. Trailing
|
||||
* slashes and an absent locale prefix on either side are tolerated so a link
|
||||
* built as "/en/about" still matches a location of "/en/about/".
|
||||
*/
|
||||
export function isCurrentPath(href: string, pathname: string): boolean {
|
||||
return normalizeForCompare(href) === normalizeForCompare(pathname);
|
||||
}
|
||||
|
||||
function normalizeForCompare(value: string): string {
|
||||
const pathOnly = value.split(/[?#]/)[0] ?? value;
|
||||
const withoutTrailingSlash = pathOnly.replace(/\/+$/, "");
|
||||
return withoutTrailingSlash === "" ? "/" : withoutTrailingSlash.toLowerCase();
|
||||
}
|
||||
Reference in New Issue
Block a user