Martin feedback round 3: dropdown still looked 'fuerchterlich' even with the official flag-icons. Root cause: 14px vertical padding around an 18px-tall flag meant the flag occupied only 39% of the trigger height and was dwarfed by whitespace. Plus a 1px black box-shadow border made flags look 'boxy', and loading='lazy' caused empty boxes on the four menu flags the moment the <details> opened. Changes: - Flag size 24x18 -> 32x24 (+78% area, ~4:3 matches flag-icons) - Trigger padding 14px 8px -> 6px (flag now 73% of trigger width, 55% of trigger height, was 46%/39%) - Drop the artificial 1px black box-shadow outline on flags - Drop border-radius on flags (real flag-icons look better as crisp rectangles) - Drop object-fit: cover (no longer needed for SVG) - Drop loading='lazy' and decoding='async' (4 small SVGs, must be ready the moment <details> opens, not flash empty boxes) - min-height: 44px restored on trigger for WCAG 2.5.5 touch target - Menu border-radius 8 -> 10px, padding tightened, font-size 0.85 -> 0.9rem for label legibility - Two-layer box-shadow on menu for subtle elevation
155 lines
5.4 KiB
PHP
155 lines
5.4 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::flagImg($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::flagImg($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;
|
||
}
|
||
|
||
/**
|
||
* Country flag for the given locale. Renders a 24×18 <img>
|
||
* 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 <a>'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 <details>
|
||
// opens (lazy would cause a flash of empty boxes).
|
||
return '<img class="flag" src="' . $src . '" alt="" width="32" height="24">';
|
||
}
|
||
|
||
/**
|
||
* 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 <a>) 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;
|
||
}
|
||
}
|