The nav previously showed 4 inline flag buttons (DE/EN/UK/RU) on desktop and a details-based dropdown on mobile. Martin asked for one dropdown with a trigger the size of a single flag, and the 4 inline flags to go away. - LocaleSwitcher: render a single <details class='locale-switcher'> everywhere; trigger is one flag + tiny caret; menu lists all 4 with labels. - Drop the 4-inline <ul> and the locale-switcher-mobile duplicate. - CSS: replace both blocks with one compact dropdown (flag-sized trigger, 44px touch target via padding, scrolled/transparent-nav variants). - Tests: assert 4 menu options, 5 flag SVGs, single <details> dropdown, active locale is a <span aria-current>, others are <a> with hreflang. - 141/141 PHPUnit green.
157 lines
6.2 KiB
PHP
157 lines
6.2 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.
|
||
*
|
||
* Single <details>-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 <span> (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::flagSvg($this->currentLocale);
|
||
|
||
$html = '<details class="locale-switcher">';
|
||
$html .= '<summary class="locale-switcher__trigger"'
|
||
. ' aria-label="' . $ariaLabel . '"'
|
||
. ' title="' . $currentName . '"'
|
||
. '>';
|
||
$html .= '<span class="locale-switcher__current" lang="' . $currentCode . '">';
|
||
$html .= $currentFlag;
|
||
$html .= '</span>';
|
||
$html .= '<span class="locale-switcher__caret" aria-hidden="true">▾</span>';
|
||
$html .= '</summary>';
|
||
$html .= '<ul class="locale-switcher__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__option is-current"'
|
||
. ' 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="locale-switcher__option"'
|
||
. ' href="' . $url . '"'
|
||
. ' hreflang="' . $codeAttr . '"'
|
||
. ' lang="' . $codeAttr . '"'
|
||
. ' rel="alternate"'
|
||
. '>'
|
||
. $flag
|
||
. '<span class="locale-switcher__label">' . $name . '</span>'
|
||
. '</a>';
|
||
}
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ul>';
|
||
$html .= '</details>';
|
||
|
||
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;
|
||
}
|
||
}
|