initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -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();
}