diff --git a/app/Controllers/LocaleSwitcher.php b/app/Controllers/LocaleSwitcher.php index 42cd0cb..1f2a5e3 100644 --- a/app/Controllers/LocaleSwitcher.php +++ b/app/Controllers/LocaleSwitcher.php @@ -11,13 +11,18 @@ use App\Core\Locale; * Renders the language switcher widget. Pure HTML generation — no * side effects, no header writing. * - * Each option gets: - * - an inline 24×16 SVG flag (sub-Issue D), + * Single
-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. * - * Active option is rendered as a (not a link) so it cannot - * be reactivated. All options are ≥44px touch targets via CSS. + * The active option is rendered as a (not a link) so it + * cannot be reactivated. The trigger and every menu option are + * ≥44px touch targets via CSS. */ final class LocaleSwitcher { @@ -36,46 +41,6 @@ final class LocaleSwitcher 'UTF-8', ); - $html = '
'; - $html .= '
    '; - 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 .= '
  • '; - if ($isCurrent) { - $html .= '' - . $flag - . '' . $name . '' - . ''; - } else { - $url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path); - $html .= '' - . $flag - . '' . $name . '' - . ''; - } - $html .= '
  • '; - } - $html .= '
'; - - // Mobile dropdown — compact single-trigger switcher for narrow viewports $currentName = htmlspecialchars( I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale), ENT_QUOTES, @@ -83,15 +48,18 @@ final class LocaleSwitcher ); $currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8'); $currentFlag = self::flagSvg($this->currentLocale); - $html .= '
'; - $html .= ''; - $html .= ''; + + $html = '
'; + $html .= ''; + $html .= ''; $html .= $currentFlag; - $html .= '' . strtoupper($currentCode) . ''; $html .= ''; - $html .= ''; + $html .= ''; $html .= ''; - $html .= '
    '; + $html .= '
      '; foreach (Locale::SUPPORTED as $code) { $isCurrent = $code === $this->currentLocale; $name = htmlspecialchars( @@ -104,23 +72,28 @@ final class LocaleSwitcher $html .= '
    • '; if ($isCurrent) { - $html .= '' - . $flag . '' . $name . ''; + $html .= '' + . $flag + . '' . $name . '' + . ''; } else { $url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path); - $html .= '' - . $flag . '' . $name . ''; + . $flag + . '' . $name . '' + . ''; } $html .= '
    • '; } $html .= '
    '; $html .= '
'; - $html .= '
'; return $html; } diff --git a/public/css/haus-schleusingen.css b/public/css/haus-schleusingen.css index 345ba86..15f9abf 100755 --- a/public/css/haus-schleusingen.css +++ b/public/css/haus-schleusingen.css @@ -100,7 +100,6 @@ nav { padding: 0.95rem 3rem; background: var(--nav-bg); backdrop-filter: saturate(180%) blur(14px); - backdrop-filter: saturate(180%) blur(14px); border-bottom: 1px solid var(--nav-border); transition: padding 0.3s ease, @@ -962,8 +961,6 @@ nav.scrolled { width: 32px; height: 32px; background: var(--accent); - mask: url("data:image/svg+xml;utf8,") - center/contain no-repeat; mask: url("data:image/svg+xml;utf8,") center/contain no-repeat; margin-bottom: 0.5rem; @@ -1131,8 +1128,6 @@ nav.scrolled { } .form-field select { - appearance: none; - appearance: none; appearance: none; background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; @@ -1367,19 +1362,6 @@ footer { 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 */ nav.mobile-open .nav-links { display: flex; @@ -1494,79 +1476,94 @@ footer { } } -/* LOCALE SWITCHER (sub-Issue D) — desktop only */ -@media (width > 900px) { - .locale-switcher { - display: flex; - align-items: center; - gap: 0.25rem; - list-style: none; - margin: 0; - padding: 0; - } +/* LOCALE SWITCHER — single
dropdown, flag-sized trigger */ +.locale-switcher { + position: relative; + display: inline-block; } -.locale-switcher__item { - display: flex; +.locale-switcher__trigger { + 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 { - display: inline-flex; + display: flex; align-items: center; - gap: 0.4rem; - min-height: 44px; - min-width: 44px; - padding: 0.45rem 0.55rem; - border: 1px solid transparent; + gap: 0.6rem; + padding: 0.6rem 0.7rem; border-radius: 4px; text-decoration: none; - font-size: 0.78rem; - font-weight: 500; - letter-spacing: 0.04em; - color: var(--text-muted-on-dark); - background: transparent; - transition: - background 0.2s, - border-color 0.2s, - color 0.2s, - transform 0.15s; - cursor: pointer; + color: var(--dark); + font-size: 0.85rem; + min-height: 44px; +} + +.locale-switcher__option.is-current { + background: var(--cream); + color: var(--accent-strong); + font-weight: 600; } .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); + outline: none; } .locale-switcher__option .flag { @@ -1582,26 +1579,18 @@ nav.scrolled .locale-switcher__option.is-current { 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-path: inset(50%); - white-space: nowrap; - border: 0; - } - - .locale-switcher__option { - min-width: 44px; - padding: 0.55rem; - } +/* Trigger on transparent nav (top-of-page): white caret on dark bg */ +nav:not(.scrolled) .locale-switcher__trigger { + color: var(--white); } +nav:not(.scrolled) .locale-switcher__trigger:hover, +nav:not(.scrolled) .locale-switcher__trigger:focus-visible { + background: rgb(255 255 255 / 12%); +} + +/* Flag stays the same regardless of nav state — SVG defines its own colours */ + /* VISUALLY HIDDEN (a11y) */ .visually-hidden { position: absolute; @@ -1615,110 +1604,6 @@ nav.scrolled .locale-switcher__option.is-current { 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-error { margin: 0.375rem 0 0; diff --git a/tests/Controllers/LocaleSwitcherTest.php b/tests/Controllers/LocaleSwitcherTest.php index ff5e532..5dab100 100644 --- a/tests/Controllers/LocaleSwitcherTest.php +++ b/tests/Controllers/LocaleSwitcherTest.php @@ -11,20 +11,28 @@ use PHPUnit\Framework\TestCase; /** * Renders the language switcher widget and checks that: - * - 4 items, one per supported locale, + * - exactly one
dropdown, + * - 4 menu items, one per supported locale, * - the active locale is marked aria-current="true" and is a , * - inactive locales are 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. */ final class LocaleSwitcherTest extends TestCase { #[Test] - public function rendersFourItemsForAllSupportedLocales(): void + public function rendersSingleDropdownForAllSupportedLocales(): void { $html = (new LocaleSwitcher('en', '/'))->render(); - self::assertStringContainsString('
    (no -mobile suffix, no desktop
      ) + self::assertStringContainsString('
      ', $html); + self::assertStringNotContainsString('locale-switcher-mobile', $html); + self::assertStringNotContainsString('
        . The active // locale renders as (no hreflang). Together all @@ -35,10 +43,9 @@ final class LocaleSwitcherTest extends TestCase "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'); - self::assertSame(4, substr_count($html, 'class="locale-switcher-mobile__option'), 'expected 4 mobile options'); - self::assertSame(9, substr_count($html, 'class="flag"'), 'expected 9 flag SVGs (4 desktop + 4 mobile menu + 1 mobile trigger)'); + + // 1 flag in trigger + 4 flags in menu = 5 total + self::assertSame(5, substr_count($html, 'class="flag"'), 'expected 5 flag SVGs (1 trigger + 4 menu)'); } #[Test] @@ -129,4 +136,17 @@ final class LocaleSwitcherTest extends TestCase self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe); 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 in the document is the trigger + self::assertStringContainsString(' in document)'); + } }