-based dropdown shown at every viewport. The * trigger is a flag-sized button (24×16 SVG + tiny caret) that * opens to a menu of all 4 supported locales. * * Each menu option gets: * - an inline 24×16 SVG flag, * - `hreflang` and `lang` for SEO and screen readers, * - `aria-current="true"` on the active option. * * The active option is rendered as a (not a link) so it * cannot be reactivated. The trigger and every menu option are * ≥44px touch targets via CSS. */ final class LocaleSwitcher { public function __construct( private readonly string $currentLocale, private readonly string $currentPath, ) { } public function render(): string { $path = $this->sanitisePath($this->currentPath); $ariaLabel = htmlspecialchars( I18n::t('locale.switcher.aria', [], $this->currentLocale), ENT_QUOTES, 'UTF-8', ); $currentName = htmlspecialchars( I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale), ENT_QUOTES, 'UTF-8', ); $currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8'); $currentFlag = self::flagImg($this->currentLocale); $html = '
'; $html .= ''; $html .= ''; $html .= $currentFlag; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; return $html; } /** * Country flag for the given locale. Renders a 24×18 * pointing at the official flag-icons SVG asset shipped under * public/img/flags/. 4:3 aspect (de/gb/ua/ru), crisp at any DPI, * no external CDN dependency. * * Decorative: `alt=""` (the visible locale-switcher label and * the 's `hreflang`/`lang` carry the accessible name). */ public static function flagImg(string $locale): string { $src = self::flagSource($locale); // 32×24 = ~4:3, large enough that the flag is the visual // anchor of the option. No loading="lazy" — these are 4 // small SVGs that must be ready the moment the
// opens (lazy would cause a flash of empty boxes). return ''; } /** * Map our locale codes to flag-icons file names. Locale "en" * is en-GB per ADR-002, so the asset is "gb.svg". Anything we * do not know falls back to a transparent 1×1 gif so the layout * stays intact and the alt text (from the surrounding ) is * the only signal. */ private static function flagSource(string $locale): string { $file = match ($locale) { 'de' => 'de', 'en' => 'gb', 'uk' => 'ua', 'ru' => 'ru', default => null, }; if ($file === null) { return 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAACAkQBADs='; } return '/img/flags/' . $file . '.svg'; } /** * Make sure the path is safe to embed as a query string value and * a redirect target. Drops query/fragment, keeps only the path. */ private function sanitisePath(string $path): string { $path = parse_url($path, PHP_URL_PATH) ?: '/'; if ($path === '' || $path[0] !== '/') { return '/'; } return $path; } }