184 lines
7.6 KiB
PHP
184 lines
7.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Core\I18n;
|
||
use App\Core\Locale;
|
||
|
||
/**
|
||
* Renders the language switcher widget. Pure HTML generation — no
|
||
* side effects, no header writing.
|
||
*
|
||
* 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
|
||
{
|
||
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',
|
||
);
|
||
|
||
$html = '<div class="locale-switcher-wrapper">';
|
||
$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',
|
||
);
|
||
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
|
||
$flag = self::flagSvg($code);
|
||
|
||
$classes = 'locale-switcher__option';
|
||
if ($isCurrent) {
|
||
$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 class="' . $classes . '"'
|
||
. ' href="' . $url . '"'
|
||
. ' hreflang="' . $codeAttr . '"'
|
||
. ' lang="' . $codeAttr . '"'
|
||
. ' rel="alternate"'
|
||
. '>'
|
||
. $flag
|
||
. '<span class="locale-switcher__label">' . $name . '</span>'
|
||
. '</a>';
|
||
}
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ul>';
|
||
|
||
// Mobile dropdown — compact single-trigger switcher for narrow viewports
|
||
$currentName = htmlspecialchars(
|
||
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
|
||
ENT_QUOTES,
|
||
'UTF-8',
|
||
);
|
||
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
|
||
$currentFlag = self::flagSvg($this->currentLocale);
|
||
$html .= '<details class="locale-switcher-mobile">';
|
||
$html .= '<summary class="locale-switcher-mobile__trigger" aria-label="' . $ariaLabel . '">';
|
||
$html .= '<span class="locale-switcher-mobile__current" lang="' . $currentCode . '">';
|
||
$html .= $currentFlag;
|
||
$html .= '<span class="locale-switcher-mobile__current-code">' . strtoupper($currentCode) . '</span>';
|
||
$html .= '</span>';
|
||
$html .= '<span class="locale-switcher-mobile__caret" aria-hidden="true">▾</span>';
|
||
$html .= '</summary>';
|
||
$html .= '<ul class="locale-switcher-mobile__menu" role="list">';
|
||
foreach (Locale::SUPPORTED as $code) {
|
||
$isCurrent = $code === $this->currentLocale;
|
||
$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>';
|
||
if ($isCurrent) {
|
||
$html .= '<span class="locale-switcher-mobile__option is-current" aria-current="true" lang="' . $codeAttr . '">'
|
||
. $flag . '<span>' . $name . '</span></span>';
|
||
} else {
|
||
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
||
$html .= '<a class="locale-switcher-mobile__option"'
|
||
. ' href="' . $url . '"'
|
||
. ' hreflang="' . $codeAttr . '"'
|
||
. ' lang="' . $codeAttr . '"'
|
||
. ' rel="alternate"'
|
||
. '>'
|
||
. $flag . '<span>' . $name . '</span></a>';
|
||
}
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ul>';
|
||
$html .= '</details>';
|
||
$html .= '</div>';
|
||
|
||
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.
|
||
*/
|
||
private function sanitisePath(string $path): string
|
||
{
|
||
$path = parse_url($path, PHP_URL_PATH) ?: '/';
|
||
if ($path === '' || $path[0] !== '/') {
|
||
return '/';
|
||
}
|
||
return $path;
|
||
}
|
||
}
|