Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
@@ -11,10 +11,13 @@ use App\Core\Locale;
|
|||||||
* Renders the language switcher widget. Pure HTML generation — no
|
* Renders the language switcher widget. Pure HTML generation — no
|
||||||
* side effects, no header writing.
|
* side effects, no header writing.
|
||||||
*
|
*
|
||||||
* Output is semantic HTML (a <ul> of links) with `aria-current` for the
|
* Each option gets:
|
||||||
* active locale, `hreflang` for SEO, and `lang` for screen readers.
|
* - an inline 24×16 SVG flag (sub-Issue D),
|
||||||
* The basic list-of-locale-codes is the MVP. Sub-Issue D (responsive
|
* - `hreflang` and `lang` for SEO and screen readers,
|
||||||
* SVG flag UI) refines the presentation.
|
* - `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
|
final class LocaleSwitcher
|
||||||
{
|
{
|
||||||
@@ -36,17 +39,35 @@ final class LocaleSwitcher
|
|||||||
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
|
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
|
||||||
foreach (Locale::SUPPORTED as $code) {
|
foreach (Locale::SUPPORTED as $code) {
|
||||||
$isCurrent = $code === $this->currentLocale;
|
$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');
|
$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) {
|
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 {
|
} else {
|
||||||
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
||||||
$html .= '<a href="' . $url . '" hreflang="' . $codeAttr . '" lang="' . $codeAttr . '" rel="alternate">'
|
$html .= '<a class="' . $classes . '"'
|
||||||
. $codeAttr
|
. ' href="' . $url . '"'
|
||||||
. '<span class="visually-hidden"> (' . $name . ')</span>'
|
. ' hreflang="' . $codeAttr . '"'
|
||||||
|
. ' lang="' . $codeAttr . '"'
|
||||||
|
. ' rel="alternate"'
|
||||||
|
. '>'
|
||||||
|
. $flag
|
||||||
|
. '<span class="locale-switcher__label">' . $name . '</span>'
|
||||||
. '</a>';
|
. '</a>';
|
||||||
}
|
}
|
||||||
$html .= '</li>';
|
$html .= '</li>';
|
||||||
@@ -56,6 +77,49 @@ final class LocaleSwitcher
|
|||||||
return $html;
|
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
|
* Make sure the path is safe to embed as a query string value and
|
||||||
* a redirect target. Drops query/fragment, keeps only the path.
|
* a redirect target. Drops query/fragment, keeps only the path.
|
||||||
|
|||||||
@@ -48,14 +48,6 @@ final class I18n
|
|||||||
}
|
}
|
||||||
$text ??= $key;
|
$text ??= $key;
|
||||||
return self::interpolate($text, $params);
|
return self::interpolate($text, $params);
|
||||||
|
|
||||||
if ($params !== []) {
|
|
||||||
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
|
|
||||||
$replace = array_values($params);
|
|
||||||
$text = str_replace($search, $replace, $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1316,3 +1316,122 @@ footer {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* LOCALE SWITCHER (sub-Issue D) */
|
||||||
|
.locale-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgb(255 255 255 / 90%);
|
||||||
|
background: transparent;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option:hover,
|
||||||
|
.locale-switcher__option:focus-visible {
|
||||||
|
background: rgb(255 255 255 / 14%);
|
||||||
|
border-color: rgb(255 255 255 / 30%);
|
||||||
|
color: var(--white);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option.is-current {
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
|
border-color: var(--accent-light);
|
||||||
|
color: var(--white);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option.is-current:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.scrolled .locale-switcher__option {
|
||||||
|
color: var(--stone);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.scrolled .locale-switcher__option:hover,
|
||||||
|
nav.scrolled .locale-switcher__option:focus-visible {
|
||||||
|
background: var(--warm);
|
||||||
|
border-color: var(--stone);
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.scrolled .locale-switcher__option.is-current {
|
||||||
|
background: var(--cream);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option .flag {
|
||||||
|
width: 24px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide labels on small screens, keep the 44px flag target */
|
||||||
|
@media (width <= 720px) {
|
||||||
|
.locale-switcher__label {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__option {
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VISUALLY HIDDEN (a11y) */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|||||||
128
tests/Controllers/LocaleSwitcherTest.php
Normal file
128
tests/Controllers/LocaleSwitcherTest.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Controllers;
|
||||||
|
|
||||||
|
use App\Controllers\LocaleSwitcher;
|
||||||
|
use App\Core\Locale;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the language switcher widget and checks that:
|
||||||
|
* - 4 items, one per supported locale,
|
||||||
|
* - the active locale is marked aria-current="true" and is a <span>,
|
||||||
|
* - inactive locales are <a> links to /locale?set=...&return=...,
|
||||||
|
* - every item contains a flag SVG,
|
||||||
|
* - the rendered label is in the current locale's language.
|
||||||
|
*/
|
||||||
|
final class LocaleSwitcherTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function rendersFourItemsForAllSupportedLocales(): void
|
||||||
|
{
|
||||||
|
$html = (new LocaleSwitcher('en', '/'))->render();
|
||||||
|
self::assertStringContainsString('<ul class="locale-switcher"', $html);
|
||||||
|
|
||||||
|
// The 3 inactive locales render as <a hreflang="..">. The active
|
||||||
|
// locale renders as <span lang=".."> (no hreflang). Together all
|
||||||
|
// 4 must be present in either form.
|
||||||
|
foreach (Locale::SUPPORTED as $code) {
|
||||||
|
self::assertTrue(
|
||||||
|
str_contains($html, 'hreflang="' . $code . '"') || str_contains($html, 'lang="' . $code . '"'),
|
||||||
|
"locale '$code' is missing from switcher",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::assertSame(4, substr_count($html, 'class="flag"'), 'expected 4 flag SVGs');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function marksCurrentLocaleWithAriaCurrentAndSpan(): void
|
||||||
|
{
|
||||||
|
$html = (new LocaleSwitcher('uk', '/'))->render();
|
||||||
|
self::assertStringContainsString('is-current', $html);
|
||||||
|
self::assertStringContainsString('aria-current="true"', $html);
|
||||||
|
self::assertStringContainsString('lang="uk"', $html);
|
||||||
|
|
||||||
|
// active option must be a <span>, not an <a>
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/<span class="locale-switcher__option is-current"[^>]*aria-current="true"[^>]*lang="uk"/',
|
||||||
|
$html,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function inactiveLocalesAreLinksToLocaleController(): void
|
||||||
|
{
|
||||||
|
$html = (new LocaleSwitcher('de', '/foo/bar'))->render();
|
||||||
|
self::assertStringContainsString('href="/locale?set=en&return=%2Ffoo%2Fbar"', $html);
|
||||||
|
self::assertStringContainsString('href="/locale?set=uk&return=%2Ffoo%2Fbar"', $html);
|
||||||
|
self::assertStringContainsString('href="/locale?set=ru&return=%2Ffoo%2Fbar"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function stripsQueryAndFragmentFromReturnPath(): void
|
||||||
|
{
|
||||||
|
$html = (new LocaleSwitcher('de', '/?lang=uk#kontakt'))->render();
|
||||||
|
// sanitisePath keeps only the path part
|
||||||
|
self::assertStringContainsString('return=%2F', $html);
|
||||||
|
self::assertStringNotContainsString('return=%2F%3Flang', $html);
|
||||||
|
self::assertStringNotContainsString('return=%2F%23kontakt', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejectsPathsThatDoNotStartWithSlash(): void
|
||||||
|
{
|
||||||
|
$html = (new LocaleSwitcher('de', 'https://evil.example/'))->render();
|
||||||
|
// sanitisePath falls back to '/'
|
||||||
|
self::assertStringContainsString('return=%2F', $html);
|
||||||
|
self::assertStringNotContainsString('evil.example', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string, string}>
|
||||||
|
*/
|
||||||
|
public static function flagDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'DE Germany' => ['de', '#FFCC00'],
|
||||||
|
'EN UnionJack' => ['en', '#C8102E'],
|
||||||
|
'UK Ukraine' => ['uk', '#FFD500'],
|
||||||
|
'RU Russia' => ['ru', '#D52B1E'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function flagSvgReturnsValidSvgForEverySupportedLocale(): void
|
||||||
|
{
|
||||||
|
foreach (Locale::SUPPORTED as $code) {
|
||||||
|
$svg = LocaleSwitcher::flagSvg($code);
|
||||||
|
self::assertStringStartsWith('<svg', $svg);
|
||||||
|
self::assertStringContainsString('viewBox="0 0 24 16"', $svg);
|
||||||
|
self::assertStringContainsString('aria-hidden="true"', $svg);
|
||||||
|
self::assertStringContainsString('focusable="false"', $svg);
|
||||||
|
self::assertStringContainsString('class="flag"', $svg);
|
||||||
|
self::assertStringEndsWith('</svg>', $svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function flagSvgHasFallbackForUnknownLocale(): void
|
||||||
|
{
|
||||||
|
$svg = LocaleSwitcher::flagSvg('xx');
|
||||||
|
self::assertStringStartsWith('<svg', $svg);
|
||||||
|
self::assertStringContainsString('class="flag"', $svg);
|
||||||
|
self::assertStringEndsWith('</svg>', $svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function ariaLabelUsesCurrentLocaleName(): void
|
||||||
|
{
|
||||||
|
$htmlDe = (new LocaleSwitcher('de', '/'))->render();
|
||||||
|
$htmlEn = (new LocaleSwitcher('en', '/'))->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
|
||||||
|
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user