Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
@@ -47,7 +47,7 @@ final class LocaleSwitcher
|
||||
'UTF-8',
|
||||
);
|
||||
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
|
||||
$currentFlag = self::flagSvg($this->currentLocale);
|
||||
$currentFlag = self::flagImg($this->currentLocale);
|
||||
|
||||
$html = '<details class="locale-switcher">';
|
||||
$html .= '<summary class="locale-switcher__trigger"'
|
||||
@@ -68,7 +68,7 @@ final class LocaleSwitcher
|
||||
'UTF-8',
|
||||
);
|
||||
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
|
||||
$flag = self::flagSvg($code);
|
||||
$flag = self::flagImg($code);
|
||||
|
||||
$html .= '<li>';
|
||||
if ($isCurrent) {
|
||||
@@ -99,46 +99,40 @@ final class LocaleSwitcher
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline 24×16 SVG for the four supported locales.
|
||||
* 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.
|
||||
*
|
||||
* - 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.
|
||||
* Decorative: `alt=""` (the visible locale-switcher label and
|
||||
* the <a>'s `hreflang`/`lang` carry the accessible name).
|
||||
*/
|
||||
public static function flagSvg(string $locale): string
|
||||
public static function flagImg(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>',
|
||||
$src = self::flagSource($locale);
|
||||
return '<img class="flag" src="' . $src . '" alt="" width="24" height="18" loading="lazy" decoding="async">';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
return $svg;
|
||||
if ($file === null) {
|
||||
return 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAACAkQBADs=';
|
||||
}
|
||||
return '/img/flags/' . $file . '.svg';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1568,20 +1568,22 @@ footer {
|
||||
|
||||
.locale-switcher .flag {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
height: 18px;
|
||||
flex: 0 0 24px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.locale-switcher__option .flag {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
height: 18px;
|
||||
flex: 0 0 24px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.locale-switcher__label {
|
||||
|
||||
5
public/img/flags/de.svg
Normal file
5
public/img/flags/de.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
|
||||
<path fill="#fc0" d="M0 320h640v160H0z"/>
|
||||
<path fill="#000001" d="M0 0h640v160H0z"/>
|
||||
<path fill="red" d="M0 160h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 221 B |
7
public/img/flags/en.svg
Normal file
7
public/img/flags/en.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 640 480">
|
||||
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0z"/>
|
||||
<path fill="#C8102E" d="m424 281 216 159v40L369 281zm-184 20 6 35L54 480H0zM640 0v3L391 191l2-44L590 0zM0 0l239 176h-60L0 42z"/>
|
||||
<path fill="#FFF" d="M241 0v480h160V0zM0 160v160h640V160z"/>
|
||||
<path fill="#C8102E" d="M0 193v96h640v-96zM273 0v480h96V0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
5
public/img/flags/ru.svg
Normal file
5
public/img/flags/ru.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 0h640v160H0z"/>
|
||||
<path fill="#0039a6" d="M0 160h640v160H0z"/>
|
||||
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
6
public/img/flags/uk.svg
Normal file
6
public/img/flags/uk.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="gold" d="M0 0h640v480H0z"/>
|
||||
<path fill="#0057b8" d="M0 0h640v240H0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -97,34 +97,35 @@ final class LocaleSwitcherTest extends TestCase
|
||||
public static function flagDataProvider(): array
|
||||
{
|
||||
return [
|
||||
'DE Germany' => ['de', '#FFCC00'],
|
||||
'EN UnionJack' => ['en', '#C8102E'],
|
||||
'UK Ukraine' => ['uk', '#FFD500'],
|
||||
'RU Russia' => ['ru', '#D52B1E'],
|
||||
'DE Germany' => ['de', 'de.svg'],
|
||||
'EN en-GB' => ['en', 'gb.svg'],
|
||||
'UK Ukraine' => ['uk', 'ua.svg'],
|
||||
'RU Russia' => ['ru', 'ru.svg'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function flagSvgReturnsValidSvgForEverySupportedLocale(): void
|
||||
public function flagImgReturnsValidImgForEverySupportedLocale(): 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);
|
||||
$img = LocaleSwitcher::flagImg($code);
|
||||
self::assertStringStartsWith('<img', $img);
|
||||
self::assertStringContainsString('class="flag"', $img);
|
||||
self::assertStringContainsString('width="24" height="18"', $img);
|
||||
self::assertStringContainsString('alt=""', $img);
|
||||
self::assertStringEndsWith('>', $img);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function flagSvgHasFallbackForUnknownLocale(): void
|
||||
public function flagImgHasFallbackForUnknownLocale(): void
|
||||
{
|
||||
$svg = LocaleSwitcher::flagSvg('xx');
|
||||
self::assertStringStartsWith('<svg', $svg);
|
||||
self::assertStringContainsString('class="flag"', $svg);
|
||||
self::assertStringEndsWith('</svg>', $svg);
|
||||
$img = LocaleSwitcher::flagImg('xx');
|
||||
self::assertStringStartsWith('<img', $img);
|
||||
self::assertStringContainsString('class="flag"', $img);
|
||||
// 1×1 transparent gif keeps the layout stable even when the
|
||||
// locale code is not one of our four.
|
||||
self::assertStringContainsString('data:image/gif', $img);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -142,11 +143,11 @@ final class LocaleSwitcherTest extends TestCase
|
||||
{
|
||||
// The closed dropdown shows the current locale's flag in the trigger
|
||||
$html = (new LocaleSwitcher('de', '/'))->render();
|
||||
// The first <svg class="flag"> in the document is the trigger
|
||||
self::assertStringContainsString('<svg class="flag" viewBox="0 0 24 16"', $html);
|
||||
// The first flag SVG must contain the German colours
|
||||
$deFlag = LocaleSwitcher::flagSvg('de');
|
||||
// The first <img class="flag"> in the document is the trigger and it
|
||||
// must point at the German flag asset under /img/flags/.
|
||||
$deFlag = LocaleSwitcher::flagImg('de');
|
||||
$pos = strpos($html, $deFlag);
|
||||
self::assertNotFalse($pos, 'expected German flag SVG in the trigger (first <svg> in document)');
|
||||
self::assertNotFalse($pos, 'expected German flag <img> in the trigger (first <img class="flag"> in document)');
|
||||
self::assertStringContainsString('src="/img/flags/de.svg"', $deFlag);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user