feat(i18n): responsive locale-switcher with SVG flags (closes #75)
This commit is contained in:
@@ -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) . '&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.
|
||||
|
||||
Reference in New Issue
Block a user