From b9d4d5ed7f0923b690aa91174297aecec509dab8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:16:54 +0200 Subject: [PATCH] feat: add search and Twemoji flags to the language picker --- NOTICE | 7 + .../ui/__tests__/language-switcher.test.tsx | 72 ++++ components/ui/flag-icons.tsx | 392 ++++-------------- components/ui/language-switcher.tsx | 201 ++++++--- 4 files changed, 306 insertions(+), 366 deletions(-) create mode 100644 components/ui/__tests__/language-switcher.test.tsx diff --git a/NOTICE b/NOTICE index ad2b31317..284947e7e 100644 --- a/NOTICE +++ b/NOTICE @@ -32,3 +32,10 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-party artwork: + +- Flag icons in components/ui/flag-icons.tsx are from Twemoji + (https://github.com/jdecked/twemoji), Copyright 2019 Twitter, Inc and + other contributors, licensed under CC-BY 4.0 + (https://creativecommons.org/licenses/by/4.0/). diff --git a/components/ui/__tests__/language-switcher.test.tsx b/components/ui/__tests__/language-switcher.test.tsx new file mode 100644 index 000000000..08ab0148e --- /dev/null +++ b/components/ui/__tests__/language-switcher.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => 'de' })); + +import { useLocaleStore } from '@/stores/locale-store'; +import { LanguageSwitcher } from '../language-switcher'; + +const setLocale = vi.fn(); + +function openMenu() { + render(); + fireEvent.click(screen.getByRole('button', { name: /English/ })); + return screen.getByRole('combobox'); +} + +const optionNames = () => screen.getAllByRole('option').map((o) => o.textContent); + +describe('LanguageSwitcher', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); // not in jsdom + setLocale.mockClear(); + useLocaleStore.setState({ locale: 'en', setLocale }); + }); + + it('focuses the search box and starts on the current language', () => { + const input = openMenu(); + expect(document.activeElement).toBe(input); + const active = input.getAttribute('aria-activedescendant'); + expect(document.getElementById(active!)?.textContent).toContain('English'); + }); + + it('finds a language by its native, UI-language or English name, ignoring accents', () => { + const input = openMenu(); + fireEvent.change(input, { target: { value: 'espanol' } }); + expect(optionNames()).toEqual([expect.stringContaining('Español')]); + + // UI language is German: "Französisch" is the display name for fr. + fireEvent.change(input, { target: { value: 'französisch' } }); + expect(optionNames()).toEqual([expect.stringContaining('Français')]); + + fireEvent.change(input, { target: { value: 'hungarian' } }); + expect(optionNames()).toEqual([expect.stringContaining('Magyar')]); + }); + + it('picks the highlighted result with the keyboard', () => { + const input = openMenu(); + fireEvent.change(input, { target: { value: 'pol' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(setLocale).toHaveBeenCalledWith('pl'); + expect(screen.queryByRole('combobox')).toBeNull(); + }); + + it('moves through results with the arrow keys', () => { + const input = openMenu(); + fireEvent.change(input, { target: { value: 'sk' } }); + expect(optionNames().slice(0, 2)).toEqual([expect.stringContaining('Česky'), expect.stringContaining('Slovenčina')]); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(setLocale).toHaveBeenCalledWith('sk'); + }); + + it('says so when nothing matches', () => { + const input = openMenu(); + fireEvent.change(input, { target: { value: 'klingon' } }); + expect(screen.queryAllByRole('option')).toHaveLength(0); + expect(screen.getByText('sharing.no_match')).toBeTruthy(); + }); +}); diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index f6c2cc285..06c871f32 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -1,397 +1,157 @@ import { type SVGProps, type ReactElement } from "react"; +// Flag artwork from Twemoji (https://github.com/jdecked/twemoji), +// (c) Twitter, Inc and other contributors, CC-BY 4.0. +// https://creativecommons.org/licenses/by/4.0/ +// The Catalan flag is not in Twemoji and is drawn here in the same style. + type FlagProps = SVGProps; -const flagClass = "inline-block rounded-[2px] shrink-0"; -const W = 20; -const H = 15; +const flagClass = "inline-block shrink-0"; -/** Great Britain – Union Jack (simplified) */ -export function FlagGB(props: FlagProps) { +// Twemoji flags fill y 5..31 of a 36x36 box; crop to the flag itself. +function Flag({ children, ...props }: FlagProps) { return ( - - - - - - + ); } -/** France – Blue, White, Red vertical */ -export function FlagFR(props: FlagProps) { - return ( - - - - - - ); +function FlagAE(props: FlagProps) { + return ; } -/** Japan – White with red circle */ -export function FlagJP(props: FlagProps) { - return ( - - - - - ); +function FlagCAT(props: FlagProps) { + return ; } -/** South Korea – Simplified */ -export function FlagKR(props: FlagProps) { - return ( - - - - - - - - - - - - - - - - - - - - ); +function FlagCZ(props: FlagProps) { + return ; } -/** Spain – Red, Yellow, Red horizontal */ -export function FlagES(props: FlagProps) { - return ( - - - - - - ); +function FlagSK(props: FlagProps) { + return ; } -/** Catalonia – Senyera: four red horizontal bars on a yellow field */ -export function FlagCAT(props: FlagProps) { - return ( - - - - - - - - ); +function FlagDK(props: FlagProps) { + return ; } -/** Italy – Green, White, Red vertical */ -export function FlagIT(props: FlagProps) { - return ( - - - - - - ); +function FlagDE(props: FlagProps) { + return ; } -/** Germany – Black, Red, Gold horizontal */ -export function FlagDE(props: FlagProps) { - return ( - - - - - - ); +function FlagGB(props: FlagProps) { + return ; } -/** Hungary – Red, White, Green horizontal */ -export function FlagHU(props: FlagProps) { - return ( - - - - - - ); +function FlagIR(props: FlagProps) { + return ; } -/** Latvia – Maroon, White, Maroon horizontal */ -export function FlagLV(props: FlagProps) { - return ( - - - - - - ); +function FlagES(props: FlagProps) { + return ; } -/** Netherlands – Red, White, Blue horizontal */ -export function FlagNL(props: FlagProps) { - return ( - - - - - - ); +function FlagFR(props: FlagProps) { + return ; } -/** Poland – White, Red horizontal */ -export function FlagPL(props: FlagProps) { - return ( - - - - - ); +function FlagIL(props: FlagProps) { + return ; } -/** Brazil – Green, yellow diamond (simplified) */ -export function FlagBR(props: FlagProps) { - return ( - - - - - - ); +function FlagIT(props: FlagProps) { + return ; } -/** Russia – White, Blue, Red horizontal */ -export function FlagRU(props: FlagProps) { - return ( - - - - - - ); +function FlagHU(props: FlagProps) { + return ; } -/** Turkey - Red with white crescent and star */ -export function FlagTR(props: FlagProps) { - return ( - - - - - - - ); +function FlagLV(props: FlagProps) { + return ; } -/** Ukraine – Blue, Yellow horizontal */ -export function FlagUA(props: FlagProps) { - return ( - - - - - ); +function FlagNL(props: FlagProps) { + return ; } -/** China – Red with yellow stars (simplified) */ -export function FlagCN(props: FlagProps) { - return ( - - - - - - - - - - - ); +function FlagNO(props: FlagProps) { + return ; } -/** Taiwan – Red field with blue canton and twelve-ray white sun */ -export function FlagTW(props: FlagProps) { - return ( - - - - - - - - ); +function FlagPL(props: FlagProps) { + return ; } -/** Czech Republic - White and red horizontal bands with a blue triangle */ -export function FlagCS(props: FlagProps) { - return ( - - - - - - ); +function FlagBR(props: FlagProps) { + return ; } -/** Denmark – Red with a white Nordic cross */ -export function FlagDK(props: FlagProps) { - return ( - - - - - ); +function FlagRO(props: FlagProps) { + return ; } -/** Norway – Red with a blue Nordic cross outlined in white */ -export function FlagNO(props: FlagProps) { - return ( - - - - - - ); +function FlagTR(props: FlagProps) { + return ; } -/** Romania – Vertical blue, yellow, red tricolour */ -export function FlagRO(props: FlagProps) { - return ( - - - - - - ); +function FlagRU(props: FlagProps) { + return ; } -/** Iran – Green, White, Red horizontal with emblem (simplified) */ -export function FlagIR(props: FlagProps) { - return ( - - - - - - - ); +function FlagUA(props: FlagProps) { + return ; } -/** Israel – white field, two blue stripes, Star of David */ -export function FlagIL(props: FlagProps) { - return ( - - - - - - - - - - ); +function FlagKR(props: FlagProps) { + return ; } -const skShield = - "m269.993 459.98-3.906-1.867c-25.267-12.173-56.294-30.4-81.294-58.133" + - "-25-27.733-43.8-65.307-43.8-114.24 0-93.6 4.52-136.68 4.52-136.68" + - "l.84-8.067h247.28l.84 8.067s4.534 43.093 4.534 136.68" + - "c0 48.933-18.8 86.507-43.814 114.24-25 27.733-56.026 45.96-81.293 58.133Z"; -const skCross = - "M280.56 261.28c13.36.22 39.45.74 62.67-7.03 0 0-.61 8.31-.61 17.99" + - " 0 9.67.61 17.98.61 17.98-21.3-7.12-47.61-7.27-62.67-7.08v51.54h-21.12" + - "v-51.54c-15.07-.2-41.37-.04-62.68 7.08 0 0 .62-8.3.62-17.98s-.62-17.99" + - "-.62-17.99c23.23 7.77 49.31 7.25 62.68 7.03v-32.37c-12.19-.1-29.74.48" + - "-49.6 7.12 0 0 .62-8.3.62-17.98s-.62-17.98-.62-17.98c19.83 6.62 37.36" + - " 7.22 49.54 7.11-.62-20.5-6.6-46.33-6.6-46.33s12.3.96 17.22.96c4.92 0" + - " 17.21-.96 17.21-.96s-5.97 25.83-6.6 46.33c12.18.1 29.72-.49 49.55-7.11" + - " 0 0-.62 8.3-.62 17.98 0 9.67.62 17.98.62 17.98-19.86-6.64-37.42-7.22-49.6-7.12v32.37"; -/** United Arab Emirates – Red hoist stripe, green/white/black horizontal bands */ -export function FlagAE(props: FlagProps) { - return ( - - - - - - - ); +function FlagJP(props: FlagProps) { + return ; } -const skHills = - "M270 329.1c-24.87 0-38.19 34.46-38.19 34.46s-7.4-16.34-27.68-16.34" + - "c-13.73 0-23.82 12.2-30.25 23.5 24.97 39.7 64.8 64.2 96.11 79.28" + - " 31.32-15.07 71.16-39.58 96.13-79.28-6.43-11.3-16.52-23.5-30.25-23.5" + - "a30.52 30.52 0 0 0-27.69 16.34s-13.32-34.46-38.19-34.46Z"; - -/** Mongolia – Red, Blue, Red vertical tricolor with Soyombo */ -export function FlagMN(props: FlagProps) { - return ( - - - - - - - - - - - - - ); +function FlagMN(props: FlagProps) { + return ; } -/** Slovakia – White, Blue, Red horizontal with coat of arms */ -export function FlagSK(props: FlagProps) { - return ( - - - - - - - - - - ); +function FlagCN(props: FlagProps) { + return ; +} + +function FlagTW(props: FlagProps) { + return ; } -/** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { + ar: FlagAE, ca: FlagCAT, - cs: FlagCS, + cs: FlagCZ, sk: FlagSK, da: FlagDK, de: FlagDE, en: FlagGB, + fa: FlagIR, es: FlagES, fr: FlagFR, - hu: FlagHU, + he: FlagIL, it: FlagIT, - ja: FlagJP, - ko: FlagKR, + hu: FlagHU, lv: FlagLV, - mn: FlagMN, - nb: FlagNO, nl: FlagNL, + nb: FlagNO, pl: FlagPL, pt: FlagBR, ro: FlagRO, - ru: FlagRU, tr: FlagTR, + ru: FlagRU, uk: FlagUA, + ko: FlagKR, + ja: FlagJP, + mn: FlagMN, zh: FlagCN, 'zh-TW': FlagTW, - fa: FlagIR, - he: FlagIL, - ar: FlagAE, }; diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index c785aece5..5126cb226 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useMemo, useId } from "react"; +import { useTranslations } from 'next-intl'; import { useLocaleStore } from '@/stores/locale-store'; -import { ChevronDown } from '@/components/icons'; +import { getEffectiveLocale } from '@/i18n/detect-locale'; +import { Check, ChevronDown, Search } from '@/components/icons'; import { cn } from '@/lib/utils'; -import { useMenuNavigation } from '@/hooks/use-menu-navigation'; import { flagComponents } from './flag-icons'; const languages = [ @@ -38,57 +39,137 @@ const languages = [ { value: 'zh-TW', label: '繁體中文(台灣)' }, ]; +// Case- and accent-insensitive, so "espanol" finds "Español". +function fold(text: string): string { + return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); +} + +function displayNames(locale: string): ((code: string) => string | undefined) { + try { + const names = new Intl.DisplayNames([locale], { type: 'language' }); + return (code) => names.of(code); + } catch { + return () => undefined; + } +} + function FlagIcon({ locale }: { locale: string }) { const Flag = flagComponents[locale]; - if (!Flag) return null; + if (!Flag) return