Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
@@ -110,7 +110,11 @@ final class LocaleSwitcher
|
|||||||
public static function flagImg(string $locale): string
|
public static function flagImg(string $locale): string
|
||||||
{
|
{
|
||||||
$src = self::flagSource($locale);
|
$src = self::flagSource($locale);
|
||||||
return '<img class="flag" src="' . $src . '" alt="" width="24" height="18" loading="lazy" decoding="async">';
|
// 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">';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1476,7 +1476,14 @@ footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* LOCALE SWITCHER — single <details> dropdown, flag-sized trigger */
|
/* LOCALE SWITCHER — single <details> dropdown, flag-sized trigger.
|
||||||
|
Design goals (after Martin-feedback round 3):
|
||||||
|
- The flag is the visual anchor of every row, not a tiny icon
|
||||||
|
drowning in padding.
|
||||||
|
- Trigger is a 44×44 touch target with the flag centred, no
|
||||||
|
artificial 1px outline (real flag-icons need no border).
|
||||||
|
- Menu rows are 40px+ tall with comfortable flag+label spacing.
|
||||||
|
- No FOUC on open: SVGs load eager (4 small files). */
|
||||||
.locale-switcher {
|
.locale-switcher {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1485,15 +1492,14 @@ footer {
|
|||||||
.locale-switcher__trigger {
|
.locale-switcher__trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 4px;
|
||||||
padding: 14px 8px;
|
padding: 6px;
|
||||||
|
min-height: 44px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
min-height: 44px;
|
|
||||||
min-width: 44px;
|
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
transition:
|
transition:
|
||||||
background 0.2s,
|
background 0.2s,
|
||||||
@@ -1516,11 +1522,10 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__caret {
|
.locale-switcher__caret {
|
||||||
font-size: 0.65rem;
|
font-size: 0.7rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
margin-left: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher[open] .locale-switcher__caret {
|
.locale-switcher[open] .locale-switcher__caret {
|
||||||
@@ -1533,25 +1538,27 @@ footer {
|
|||||||
right: 0;
|
right: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.4rem;
|
padding: 6px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border: 1px solid var(--warm);
|
border: 1px solid var(--warm);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
|
box-shadow:
|
||||||
|
0 1px 2px rgb(0 0 0 / 6%),
|
||||||
|
0 8px 24px rgb(0 0 0 / 14%);
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option {
|
.locale-switcher__option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 10px;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 8px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
min-height: 44px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option.is-current {
|
.locale-switcher__option.is-current {
|
||||||
@@ -1566,24 +1573,13 @@ footer {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flag is the visual anchor: 32×24, no border, no rounded corners
|
||||||
|
(flags look better as crisp rectangles than as pills). */
|
||||||
.locale-switcher .flag {
|
.locale-switcher .flag {
|
||||||
width: 24px;
|
width: 32px;
|
||||||
height: 18px;
|
height: 24px;
|
||||||
flex: 0 0 24px;
|
flex: 0 0 32px;
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
|
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher__option .flag {
|
|
||||||
width: 24px;
|
|
||||||
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 {
|
.locale-switcher__label {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ final class LocaleSwitcherTest extends TestCase
|
|||||||
$img = LocaleSwitcher::flagImg($code);
|
$img = LocaleSwitcher::flagImg($code);
|
||||||
self::assertStringStartsWith('<img', $img);
|
self::assertStringStartsWith('<img', $img);
|
||||||
self::assertStringContainsString('class="flag"', $img);
|
self::assertStringContainsString('class="flag"', $img);
|
||||||
self::assertStringContainsString('width="24" height="18"', $img);
|
self::assertStringContainsString('width="32" height="24"', $img);
|
||||||
self::assertStringContainsString('alt=""', $img);
|
self::assertStringContainsString('alt=""', $img);
|
||||||
self::assertStringEndsWith('>', $img);
|
self::assertStringEndsWith('>', $img);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user