Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
@@ -11,13 +11,18 @@ 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.
|
||||||
*
|
*
|
||||||
* Each option gets:
|
* Single <details>-based dropdown shown at every viewport. The
|
||||||
* - an inline 24×16 SVG flag (sub-Issue D),
|
* 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,
|
* - `hreflang` and `lang` for SEO and screen readers,
|
||||||
* - `aria-current="true"` on the active option.
|
* - `aria-current="true"` on the active option.
|
||||||
*
|
*
|
||||||
* Active option is rendered as a <span> (not a link) so it cannot
|
* The active option is rendered as a <span> (not a link) so it
|
||||||
* be reactivated. All options are ≥44px touch targets via CSS.
|
* cannot be reactivated. The trigger and every menu option are
|
||||||
|
* ≥44px touch targets via CSS.
|
||||||
*/
|
*/
|
||||||
final class LocaleSwitcher
|
final class LocaleSwitcher
|
||||||
{
|
{
|
||||||
@@ -36,46 +41,6 @@ final class LocaleSwitcher
|
|||||||
'UTF-8',
|
'UTF-8',
|
||||||
);
|
);
|
||||||
|
|
||||||
$html = '<div class="locale-switcher-wrapper">';
|
|
||||||
$html .= '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
|
|
||||||
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);
|
|
||||||
|
|
||||||
$classes = 'locale-switcher__option';
|
|
||||||
if ($isCurrent) {
|
|
||||||
$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 {
|
|
||||||
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
|
||||||
$html .= '<a class="' . $classes . '"'
|
|
||||||
. ' href="' . $url . '"'
|
|
||||||
. ' hreflang="' . $codeAttr . '"'
|
|
||||||
. ' lang="' . $codeAttr . '"'
|
|
||||||
. ' rel="alternate"'
|
|
||||||
. '>'
|
|
||||||
. $flag
|
|
||||||
. '<span class="locale-switcher__label">' . $name . '</span>'
|
|
||||||
. '</a>';
|
|
||||||
}
|
|
||||||
$html .= '</li>';
|
|
||||||
}
|
|
||||||
$html .= '</ul>';
|
|
||||||
|
|
||||||
// Mobile dropdown — compact single-trigger switcher for narrow viewports
|
|
||||||
$currentName = htmlspecialchars(
|
$currentName = htmlspecialchars(
|
||||||
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
|
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
|
||||||
ENT_QUOTES,
|
ENT_QUOTES,
|
||||||
@@ -83,15 +48,18 @@ final class LocaleSwitcher
|
|||||||
);
|
);
|
||||||
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
|
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
|
||||||
$currentFlag = self::flagSvg($this->currentLocale);
|
$currentFlag = self::flagSvg($this->currentLocale);
|
||||||
$html .= '<details class="locale-switcher-mobile">';
|
|
||||||
$html .= '<summary class="locale-switcher-mobile__trigger" aria-label="' . $ariaLabel . '">';
|
$html = '<details class="locale-switcher">';
|
||||||
$html .= '<span class="locale-switcher-mobile__current" lang="' . $currentCode . '">';
|
$html .= '<summary class="locale-switcher__trigger"'
|
||||||
|
. ' aria-label="' . $ariaLabel . '"'
|
||||||
|
. ' title="' . $currentName . '"'
|
||||||
|
. '>';
|
||||||
|
$html .= '<span class="locale-switcher__current" lang="' . $currentCode . '">';
|
||||||
$html .= $currentFlag;
|
$html .= $currentFlag;
|
||||||
$html .= '<span class="locale-switcher-mobile__current-code">' . strtoupper($currentCode) . '</span>';
|
|
||||||
$html .= '</span>';
|
$html .= '</span>';
|
||||||
$html .= '<span class="locale-switcher-mobile__caret" aria-hidden="true">▾</span>';
|
$html .= '<span class="locale-switcher__caret" aria-hidden="true">▾</span>';
|
||||||
$html .= '</summary>';
|
$html .= '</summary>';
|
||||||
$html .= '<ul class="locale-switcher-mobile__menu" role="list">';
|
$html .= '<ul class="locale-switcher__menu" role="list">';
|
||||||
foreach (Locale::SUPPORTED as $code) {
|
foreach (Locale::SUPPORTED as $code) {
|
||||||
$isCurrent = $code === $this->currentLocale;
|
$isCurrent = $code === $this->currentLocale;
|
||||||
$name = htmlspecialchars(
|
$name = htmlspecialchars(
|
||||||
@@ -104,23 +72,28 @@ final class LocaleSwitcher
|
|||||||
|
|
||||||
$html .= '<li>';
|
$html .= '<li>';
|
||||||
if ($isCurrent) {
|
if ($isCurrent) {
|
||||||
$html .= '<span class="locale-switcher-mobile__option is-current" aria-current="true" lang="' . $codeAttr . '">'
|
$html .= '<span class="locale-switcher__option is-current"'
|
||||||
. $flag . '<span>' . $name . '</span></span>';
|
. ' 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 class="locale-switcher-mobile__option"'
|
$html .= '<a class="locale-switcher__option"'
|
||||||
. ' href="' . $url . '"'
|
. ' href="' . $url . '"'
|
||||||
. ' hreflang="' . $codeAttr . '"'
|
. ' hreflang="' . $codeAttr . '"'
|
||||||
. ' lang="' . $codeAttr . '"'
|
. ' lang="' . $codeAttr . '"'
|
||||||
. ' rel="alternate"'
|
. ' rel="alternate"'
|
||||||
. '>'
|
. '>'
|
||||||
. $flag . '<span>' . $name . '</span></a>';
|
. $flag
|
||||||
|
. '<span class="locale-switcher__label">' . $name . '</span>'
|
||||||
|
. '</a>';
|
||||||
}
|
}
|
||||||
$html .= '</li>';
|
$html .= '</li>';
|
||||||
}
|
}
|
||||||
$html .= '</ul>';
|
$html .= '</ul>';
|
||||||
$html .= '</details>';
|
$html .= '</details>';
|
||||||
$html .= '</div>';
|
|
||||||
|
|
||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ nav {
|
|||||||
padding: 0.95rem 3rem;
|
padding: 0.95rem 3rem;
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
backdrop-filter: saturate(180%) blur(14px);
|
backdrop-filter: saturate(180%) blur(14px);
|
||||||
backdrop-filter: saturate(180%) blur(14px);
|
|
||||||
border-bottom: 1px solid var(--nav-border);
|
border-bottom: 1px solid var(--nav-border);
|
||||||
transition:
|
transition:
|
||||||
padding 0.3s ease,
|
padding 0.3s ease,
|
||||||
@@ -962,8 +961,6 @@ nav.scrolled {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z'/></svg>")
|
|
||||||
center/contain no-repeat;
|
|
||||||
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z'/></svg>")
|
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z'/></svg>")
|
||||||
center/contain no-repeat;
|
center/contain no-repeat;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -1131,8 +1128,6 @@ nav.scrolled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-field select {
|
.form-field select {
|
||||||
appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%237a7062' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%237a7062' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@@ -1367,19 +1362,6 @@ footer {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locale switcher: replace 4-flag list with single-trigger dropdown */
|
|
||||||
.locale-switcher {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile[open] > .locale-switcher-mobile__menu {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile slide-down nav */
|
/* Mobile slide-down nav */
|
||||||
nav.mobile-open .nav-links {
|
nav.mobile-open .nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1494,79 +1476,94 @@ footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* LOCALE SWITCHER (sub-Issue D) — desktop only */
|
/* LOCALE SWITCHER — single <details> dropdown, flag-sized trigger */
|
||||||
@media (width > 900px) {
|
|
||||||
.locale-switcher {
|
.locale-switcher {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
display: inline-block;
|
||||||
gap: 0.25rem;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__item {
|
.locale-switcher__trigger {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 14px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
color: var(--dark);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__trigger::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__trigger:hover,
|
||||||
|
.locale-switcher__trigger:focus-visible {
|
||||||
|
background: rgb(0 0 0 / 6%);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__current {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__caret {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher[open] .locale-switcher__caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--warm);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
|
||||||
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option {
|
.locale-switcher__option {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.6rem;
|
||||||
min-height: 44px;
|
padding: 0.6rem 0.7rem;
|
||||||
min-width: 44px;
|
|
||||||
padding: 0.45rem 0.55rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.78rem;
|
color: var(--dark);
|
||||||
font-weight: 500;
|
font-size: 0.85rem;
|
||||||
letter-spacing: 0.04em;
|
min-height: 44px;
|
||||||
color: var(--text-muted-on-dark);
|
}
|
||||||
background: transparent;
|
|
||||||
transition:
|
.locale-switcher__option.is-current {
|
||||||
background 0.2s,
|
background: var(--cream);
|
||||||
border-color 0.2s,
|
color: var(--accent-strong);
|
||||||
color 0.2s,
|
font-weight: 600;
|
||||||
transform 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option:hover,
|
.locale-switcher__option:hover,
|
||||||
.locale-switcher__option:focus-visible {
|
.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);
|
background: var(--warm);
|
||||||
border-color: var(--stone);
|
outline: none;
|
||||||
color: var(--dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.scrolled .locale-switcher__option.is-current {
|
|
||||||
background: var(--cream);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option .flag {
|
.locale-switcher__option .flag {
|
||||||
@@ -1582,26 +1579,18 @@ nav.scrolled .locale-switcher__option.is-current {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide labels on small screens, keep the 44px flag target */
|
/* Trigger on transparent nav (top-of-page): white caret on dark bg */
|
||||||
@media (width <= 720px) {
|
nav:not(.scrolled) .locale-switcher__trigger {
|
||||||
.locale-switcher__label {
|
color: var(--white);
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-switcher__option {
|
nav:not(.scrolled) .locale-switcher__trigger:hover,
|
||||||
min-width: 44px;
|
nav:not(.scrolled) .locale-switcher__trigger:focus-visible {
|
||||||
padding: 0.55rem;
|
background: rgb(255 255 255 / 12%);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flag stays the same regardless of nav state — SVG defines its own colours */
|
||||||
|
|
||||||
/* VISUALLY HIDDEN (a11y) */
|
/* VISUALLY HIDDEN (a11y) */
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1615,110 +1604,6 @@ nav.scrolled .locale-switcher__option.is-current {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MOBILE LOCALE SWITCHER (dropdown — defaults to hidden on desktop) */
|
|
||||||
@media (width > 900px) {
|
|
||||||
.locale-switcher-mobile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.55rem 0.7rem;
|
|
||||||
border: 1px solid rgb(0 0 0 / 12%);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgb(255 255 255 / 95%);
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
min-height: 44px;
|
|
||||||
color: var(--dark);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__trigger::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__current {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__current-code {
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__caret {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile[open] .locale-switcher-mobile__caret {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
right: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.4rem;
|
|
||||||
list-style: none;
|
|
||||||
background: var(--white);
|
|
||||||
border: 1px solid var(--warm);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
|
||||||
z-index: 60;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.6rem 0.7rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--dark);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__option.is-current {
|
|
||||||
background: var(--cream);
|
|
||||||
color: var(--accent-strong);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-switcher-mobile__option:hover,
|
|
||||||
.locale-switcher-mobile__option:focus-visible {
|
|
||||||
background: var(--warm);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.scrolled .locale-switcher-mobile__trigger {
|
|
||||||
background: var(--white);
|
|
||||||
color: var(--dark);
|
|
||||||
border-color: var(--warm);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav:not(.scrolled) .locale-switcher-mobile__trigger {
|
|
||||||
background: rgb(255 255 255 / 12%);
|
|
||||||
color: var(--white);
|
|
||||||
border-color: rgb(255 255 255 / 30%);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FORM FIELD ERRORS (sub-Issue E) */
|
/* FORM FIELD ERRORS (sub-Issue E) */
|
||||||
.form-field-error {
|
.form-field-error {
|
||||||
margin: 0.375rem 0 0;
|
margin: 0.375rem 0 0;
|
||||||
|
|||||||
@@ -11,20 +11,28 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the language switcher widget and checks that:
|
* Renders the language switcher widget and checks that:
|
||||||
* - 4 items, one per supported locale,
|
* - exactly one <details class="locale-switcher"> dropdown,
|
||||||
|
* - 4 menu items, one per supported locale,
|
||||||
* - the active locale is marked aria-current="true" and is a <span>,
|
* - the active locale is marked aria-current="true" and is a <span>,
|
||||||
* - inactive locales are <a> links to /locale?set=...&return=...,
|
* - inactive locales are <a> links to /locale?set=...&return=...,
|
||||||
* - every item contains a flag SVG,
|
* - the trigger and every menu item contain a flag SVG,
|
||||||
* - the rendered label is in the current locale's language.
|
* - the rendered label is in the current locale's language.
|
||||||
*/
|
*/
|
||||||
final class LocaleSwitcherTest extends TestCase
|
final class LocaleSwitcherTest extends TestCase
|
||||||
{
|
{
|
||||||
#[Test]
|
#[Test]
|
||||||
public function rendersFourItemsForAllSupportedLocales(): void
|
public function rendersSingleDropdownForAllSupportedLocales(): void
|
||||||
{
|
{
|
||||||
$html = (new LocaleSwitcher('en', '/'))->render();
|
$html = (new LocaleSwitcher('en', '/'))->render();
|
||||||
self::assertStringContainsString('<ul class="locale-switcher"', $html);
|
|
||||||
self::assertStringContainsString('<details class="locale-switcher-mobile"', $html);
|
// exactly one <details class="locale-switcher"> (no -mobile suffix, no desktop <ul>)
|
||||||
|
self::assertStringContainsString('<details class="locale-switcher">', $html);
|
||||||
|
self::assertStringNotContainsString('locale-switcher-mobile', $html);
|
||||||
|
self::assertStringNotContainsString('<ul class="locale-switcher"', $html);
|
||||||
|
self::assertStringNotContainsString('locale-switcher__item', $html);
|
||||||
|
|
||||||
|
// the menu lists all 4 supported locales
|
||||||
|
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 menu options');
|
||||||
|
|
||||||
// The 3 inactive locales render as <a hreflang="..">. The active
|
// The 3 inactive locales render as <a hreflang="..">. The active
|
||||||
// locale renders as <span lang=".."> (no hreflang). Together all
|
// locale renders as <span lang=".."> (no hreflang). Together all
|
||||||
@@ -35,10 +43,9 @@ final class LocaleSwitcherTest extends TestCase
|
|||||||
"locale '$code' is missing from switcher",
|
"locale '$code' is missing from switcher",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Both the desktop list and the mobile dropdown render a flag per locale
|
|
||||||
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 desktop options');
|
// 1 flag in trigger + 4 flags in menu = 5 total
|
||||||
self::assertSame(4, substr_count($html, 'class="locale-switcher-mobile__option'), 'expected 4 mobile options');
|
self::assertSame(5, substr_count($html, 'class="flag"'), 'expected 5 flag SVGs (1 trigger + 4 menu)');
|
||||||
self::assertSame(9, substr_count($html, 'class="flag"'), 'expected 9 flag SVGs (4 desktop + 4 mobile menu + 1 mobile trigger)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -129,4 +136,17 @@ final class LocaleSwitcherTest extends TestCase
|
|||||||
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
|
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
|
||||||
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
|
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function triggerContainsCurrentLocaleFlag(): void
|
||||||
|
{
|
||||||
|
// 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');
|
||||||
|
$pos = strpos($html, $deFlag);
|
||||||
|
self::assertNotFalse($pos, 'expected German flag SVG in the trigger (first <svg> in document)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user