feat(i18n): responsive locale-switcher with SVG flags (closes #75)

This commit is contained in:
Hermes
2026-06-04 09:44:40 +00:00
parent 4b1c779846
commit 0186de90ec
4 changed files with 322 additions and 19 deletions

View File

@@ -11,10 +11,13 @@ use App\Core\Locale;
* Renders the language switcher widget. Pure HTML generation — no
* side effects, no header writing.
*
* Output is semantic HTML (a <ul> of links) with `aria-current` for the
* active locale, `hreflang` for SEO, and `lang` for screen readers.
* The basic list-of-locale-codes is the MVP. Sub-Issue D (responsive
* SVG flag UI) refines the presentation.
* Each option gets:
* - an inline 24×16 SVG flag (sub-Issue D),
* - `hreflang` and `lang` for SEO and screen readers,
* - `aria-current="true"` on the active option.
*
* Active option is rendered as a <span> (not a link) so it cannot
* be reactivated. All options are ≥44px touch targets via CSS.
*/
final class LocaleSwitcher
{
@@ -26,7 +29,7 @@ final class LocaleSwitcher
public function render(): string
{
$path = $this->sanitisePath($this->currentPath);
$path = $this->sanitisePath($this->currentPath);
$ariaLabel = htmlspecialchars(
I18n::t('locale.switcher.aria', [], $this->currentLocale),
ENT_QUOTES,
@@ -36,17 +39,35 @@ final class LocaleSwitcher
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
foreach (Locale::SUPPORTED as $code) {
$isCurrent = $code === $this->currentLocale;
$name = htmlspecialchars(I18n::t('locale.' . $code, [], $this->currentLocale), ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars(
I18n::t('locale.' . $code, [], $this->currentLocale),
ENT_QUOTES,
'UTF-8',
);
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
$flag = self::flagSvg($code);
$html .= '<li' . ($isCurrent ? ' class="is-current" aria-current="true"' : '') . '>';
$classes = 'locale-switcher__option';
if ($isCurrent) {
$html .= '<span lang="' . $codeAttr . '">' . $codeAttr . '<span class="visually-hidden"> (' . $name . ')</span></span>';
$classes .= ' is-current';
}
$html .= '<li class="locale-switcher__item">';
if ($isCurrent) {
$html .= '<span class="' . $classes . '" aria-current="true" lang="' . $codeAttr . '">'
. $flag
. '<span class="locale-switcher__label">' . $name . '</span>'
. '</span>';
} else {
$url = '/locale?set=' . rawurlencode($code) . '&amp;return=' . rawurlencode($path);
$html .= '<a href="' . $url . '" hreflang="' . $codeAttr . '" lang="' . $codeAttr . '" rel="alternate">'
. $codeAttr
. '<span class="visually-hidden"> (' . $name . ')</span>'
$html .= '<a class="' . $classes . '"'
. ' href="' . $url . '"'
. ' hreflang="' . $codeAttr . '"'
. ' lang="' . $codeAttr . '"'
. ' rel="alternate"'
. '>'
. $flag
. '<span class="locale-switcher__label">' . $name . '</span>'
. '</a>';
}
$html .= '</li>';
@@ -56,6 +77,49 @@ final class LocaleSwitcher
return $html;
}
/**
* Inline 24×16 SVG for the four supported locales.
*
* - DE: black/red/gold horizontal stripes (Germany)
* - EN: simplified Union Jack (en-GB per ADR-002)
* - UK: blue/yellow horizontal stripes (Ukraine)
* - RU: white/blue/red horizontal stripes (Russia)
*
* Decorative: marked `aria-hidden="true"` + `focusable="false"`.
* The full accessible name is conveyed by the visible label and
* the <a>'s hreflang/lang.
*/
public static function flagSvg(string $locale): string
{
$svg = match ($locale) {
'de' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="5.33" fill="#000"/>'
. '<rect y="5.33" width="24" height="5.34" fill="#DD0000"/>'
. '<rect y="10.67" width="24" height="5.33" fill="#FFCC00"/>'
. '</svg>',
'en' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="16" fill="#012169"/>'
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#fff" stroke-width="2.4"/>'
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#C8102E" stroke-width="1.2"/>'
. '<path d="M12,0 V16 M0,8 H24" stroke="#fff" stroke-width="3.2"/>'
. '<path d="M12,0 V16 M0,8 H24" stroke="#C8102E" stroke-width="1.6"/>'
. '</svg>',
'uk' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="8" fill="#005BBB"/>'
. '<rect y="8" width="24" height="8" fill="#FFD500"/>'
. '</svg>',
'ru' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="5.33" fill="#fff"/>'
. '<rect y="5.33" width="24" height="5.34" fill="#0039A6"/>'
. '<rect y="10.67" width="24" height="5.33" fill="#D52B1E"/>'
. '</svg>',
default => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="16" fill="#888"/>'
. '</svg>',
};
return $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.