Theme
Design your own
On this page (28)
Component Skill — components/language-switcher/component-skill.md

Native basis

A <div class="language-switcher"> with data-languages - the runtime writes a shipped Combobox (Popover API listbox) or a Swap (checkbox faces) into it; names from a preseeded table and Intl.DisplayNames

Web Platform APIs

Intl.DisplayNamesIntl.LocalePopover APICSS Anchor Positioninglang / dirWAI-ARIA Combobox

Classes

.language-switcher.language-switcher-flag.language-switcher-label.language-switcher-option

Data attributes

• data-languages - the supported tags (en de, dialects de-AT, scripts zh-Latn) · data-value - the one shown

• data-variant="swap" · data-display="label|code|icon" · data-names="both|native|ui"

• data-flag-shape="rect|rounded|square|squircle|circle" · data-flag-style="flat|ring|shadow|glossy" · data-flag-size="xs..xl" · data-flags="en:us" · data-flag="none"

Notes

• A flag stands for a region, not a language: name the dialect (en-US) or override it (data-flags="en:us")

• The switcher never translates - bind(el, i18n) sets the defuss-i18n locale and follows it back

§A list to pick from

§Eight languages

Each language in itself, and in the language shown when that differs. Eight languages and more bring the search field.

§Icon only

The flag alone - the accessible name still says the language: Language: English.

§Code

The code in place of the name, beside a round flag - the condensed form for footers and dense bars.

§Swap: one click per language

§Swap

Every click turns the Swap over to the next language - for two to four of them.

§Swap, icon only

Round flags that flip; the code under each flag shows while it loads.

§Dialects

§Eleven dialects

A tag with a region is a language of its own: its CLDR name and its region's flag.

§Names in the language shown

data-names="ui" names every language in the one shown - pick Deutsch and the list reads Englisch, Französisch, Japanisch.

§Every CLD3 language

All 107 codes CLD3 detects, script variants included - type to search in any of the names.

§Flags: shapes, scales and finishes

§Shapes and scales

Five outlines, five heights - square, squircle and circle load the 1:1 drawings.

§Finishes, light and dark

Flat, ring, shadow and glossy on the page's ground and on the dark palette - the ring takes the text color, flags dim a little in the dark.

§In a website header

§Website header

The full switcher at the end of the bar, its list opening toward the page; the compact header below keeps only the flag.

Small and quiet: the code and a round flag, or the flag alone. The list opens upward when there is no room below.

§In a toolbar

§Toolbar

Beside the editing tools: the code for an editor that writes several languages, or a one-click Swap with the flag alone.

§In a user menu

§User menu

data-variant='menu': the languages as radio items of a Dropdown Menu - the arrows reach them, Enter or a click picks one, the check moves and the menu stays open. The root is the menu's radio group (role='group' and a name).

§With defuss-i18n

§Bound to defuss-i18n

bind() sets the locale on a pick and follows it back: the buttons below set the locale, the switcher moves along.

The table behind the names holds the 107 language codes CLD3 detects (df$.shadcn.languageSwitcher.languages()): the English name, the name in the language itself and the region whose flag stands for it. Every other pair - Japanese named in German, a dialect such as de-CH - comes from the browser's Intl.DisplayNames. The flags are flag-icons 7.2.3 (MIT) from jsDelivr; configure({ flags }) points them at your own copy.

df$.shadcn.languageSwitcher.name('de-AT');        // 'Österreichisches Deutsch'
df$.shadcn.languageSwitcher.name('ja', 'de');     // 'Japanisch'
df$.shadcn.languageSwitcher.region('pt-BR');      // 'br'
df$.shadcn.languageSwitcher.choose('lang', 'fr'); // as a click: language-change fires
const unbind = df$.shadcn.languageSwitcher.bind('lang', locale); // defuss-i18n, both ways
df$.shadcn.languageSwitcher.configure({ flags: '/flags/{shape}/{region}.svg' });

§States

Named states via the shared State API, driven per switcher through the bound api:

  • default - the language shown ({ value }); setState changes it quietly, without language-change
  • open - the Combobox's list shown

The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/language-switcher-{state}.png.

Machine contract - verified against language-switcher.schema.json by bun run verify:

StateTypeValuesDefaultDescription
valuestring——The language shown - one of data-languages, mirrored on data-value.
openbooleantrue, falsefalseThe Combobox's list shown - driven through the component's own State API.

§API

Generated from language-switcher.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type LanguageSwitcherState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
The chosen language shown.
Config fieldTypeDescription
value?stringthe language: one of data-languages (a BCP 47 tag)
open
The list of languages shown (the Combobox face).
Config fieldTypeDescription
value?stringthe language: one of data-languages (a BCP 47 tag)

Every element

MemberDescription
el.api.setState<S extends LanguageSwitcherState>(name: S, config?: LanguageSwitcherStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?LanguageSwitcherStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.languageSwitcherApi.setState<S extends LanguageSwitcherState>(el: HTMLElement, name: S, config?: LanguageSwitcherStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?LanguageSwitcherStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.languageSwitcherApi.getState(el: HTMLElement): { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.languageSwitcherApi.render(state: { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.languageSwitcherApi.store(el: HTMLElement): Store<{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.languageSwitcherApi.commit<S extends LanguageSwitcherState>(el: HTMLElement, name: S, config?: LanguageSwitcherStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?LanguageSwitcherStateConfigs[S]its config
df$.shadcn.languageSwitcherStates: LanguageSwitcherState[]The declared states, 'default' first: default, open.

df$.shadcn.languageSwitcher

MemberDescription
languages(): LanguageInfo[]
Every language the switcher knows: the 107 CLD3 codes with their English name, their own name, the region of their flag and their direction.

Returns LanguageInfo[] - a copy of the list

name(tag: string, locale?: string): string
A language's name in a locale - the preseeded names for a CLD3 code in itself and in English, the browser's (CLDR) names for every other pair, the preseeded ones when the browser has none.
ArgumentTypeDescription
tagstringthe language (a BCP 47 tag: "de", "de-AT", "zh-Latn")
locale?stringthe language to name it in (default: the language itself)

Returns string - the name, capitalized for the start of a label

region(tag: string): string
The region whose flag stands for a language: its own region, else the preseeded one.
ArgumentTypeDescription
tagstringthe language (a BCP 47 tag)

Returns string - the region (ISO 3166, lowercase; '' for a language without one, like Esperanto)

choose(target: string | HTMLElement, tag: string): boolean
Chooses a language of data-languages as a click would: the face changes and language-change fires.
ArgumentTypeDescription
targetstring | HTMLElementthe .language-switcher element, its id or a selector
tagstringthe language

Returns boolean - false when the switcher does not offer it

bind(target: string | HTMLElement, i18n: LanguageSwitcherI18n): (() => void)
Ties the switcher to a defuss-i18n instance both ways: a pick sets its locale, a locale set elsewhere moves the switcher (quietly - no language-change).
ArgumentTypeDescription
targetstring | HTMLElementthe .language-switcher element, its id or a selector
i18nLanguageSwitcherI18nthe instance (createI18n) - anything with locale, setLocale() and subscribe()

Returns (() => void) - a function that unties them

configure(options: LanguageSwitcherOptions): void
Where the flags come from - set it before the first switcher draws (to self-host the SVGs).
ArgumentTypeDescription
optionsLanguageSwitcherOptionsthe flag URL pattern

Events

EventDescription
language-change
Fires when the user (or choose()) picks another language: the one chosen, the one before, its own name and its direction - bind() turns it into the i18n locale.

detail: LanguageSwitcherChangeDetail

FieldTypeDescription
valuestringthe language chosen now (a BCP 47 tag)
previousstringthe language before
namestringits name in itself ("Deutsch")
dir'ltr' | 'rtl'its writing direction

Types

TypeDescription
LanguageInfo
One language the switcher knows.
FieldTypeDescription
codestringits CLD3 code (a BCP 47 tag; iw is Hebrew, he works too)
englishstringits name in English
nativestringits name in itself
regionstringthe region whose flag stands for it (ISO 3166, lowercase; a subdivision like gb-wls); '' for none
dir'ltr' | 'rtl'its writing direction
LanguageSwitcherChangeDetail
What language-change carries.
FieldTypeDescription
valuestringthe language chosen now (a BCP 47 tag)
previousstringthe language before
namestringits name in itself ("Deutsch")
dir'ltr' | 'rtl'its writing direction
LanguageSwitcherI18n
What a defuss-i18n instance (or any locale store of that shape) offers bind().
FieldTypeDescription
localestringthe locale shown now
setLocale(locale: string) => unknownswitches the locale
subscribe?(listener: (snapshot: { locale: string }) => void) => ()follows locale changes; returns the function that stops it
LanguageSwitcherOptions
configure()'s options.
FieldTypeDescription
flags?stringthe flag URL pattern: {shape} becomes 4x3 or 1x1, {region} the region (default: flag-icons 7.2.3 on jsDelivr)

§CSS view file

/* -- Language Switcher component -------------------------------------
   The runtime writes a shipped Combobox (a Swap, or radio items for a
   Dropdown Menu) into the element; these
   rules size it to its content and draw the flags. A flag is the language's
   code (::before) under its SVG (::after): a flag that fails to load leaves
   the code readable. Shape, scale and finish are attributes on the root. */
@layer components {
  .language-switcher {
    --language-switcher-flag-height: 1rem;
    display: inline-flex;
    align-items: center;
    vertical-align: middle;
    /* -- Flag scale ----------------------------------------------- */
    &[data-flag-size="xs"] {
      --language-switcher-flag-height: 0.75rem;
    }
    &[data-flag-size="sm"] {
      --language-switcher-flag-height: 0.875rem;
    }
    &[data-flag-size="lg"] {
      --language-switcher-flag-height: 1.25rem;
    }
    &[data-flag-size="xl"] {
      --language-switcher-flag-height: 1.75rem;
    }
    /* -- Flag shapes ---------------------------------------------- */
    &[data-flag-shape="rounded"] .language-switcher-flag {
      border-radius: calc(var(--language-switcher-flag-height) * 0.25);
    }
    &[data-flag-shape="square"] .language-switcher-flag {
      inline-size: var(--language-switcher-flag-height);
    }
    &[data-flag-shape="squircle"] .language-switcher-flag {
      inline-size: var(--language-switcher-flag-height);
      border-radius: 32%;
    }
    &[data-flag-shape="circle"] .language-switcher-flag {
      inline-size: var(--language-switcher-flag-height);
      border-radius: 50%;
    }
    /* -- Flag finishes -------------------------------------------- */
    &[data-flag-style="ring"] .language-switcher-flag {
      outline: 1px solid color-mix(in oklch, var(--foreground) 22%, transparent);
      outline-offset: -1px;
    }
    &[data-flag-style="shadow"] .language-switcher-flag {
      box-shadow:
        0 0 0 1px var(--border),
        var(--shadow-sm);
    }
    &[data-flag-style="glossy"] .language-switcher-flag {
      --language-switcher-gloss: linear-gradient(
        160deg,
        oklch(1 0 0 / 0.55),
        oklch(1 0 0 / 0) 42%,
        oklch(0 0 0 / 0) 58%,
        oklch(0 0 0 / 0.2)
      );
      outline: 1px solid oklch(0 0 0 / 0.12);
      outline-offset: -1px;
    }
    /* the code shown instead of a name (a condensed footer, a toolbar) */
    &[data-display="code"] .language-switcher-label {
      font-family: var(--font-mono);
      font-size: 0.75rem;
      letter-spacing: 0.04em;
    }
  }
  .language-switcher-flag {
    position: relative;
    display: inline-block;
    flex: none;
    inline-size: calc(var(--language-switcher-flag-height) * 4 / 3);
    block-size: var(--language-switcher-flag-height);
    border-radius: 2px;
    overflow: hidden;
    background-color: var(--muted);
    transition: scale 150ms ease;
    &::before {
      content: attr(data-code);
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      font-family: var(--font-mono);
      font-size: calc(var(--language-switcher-flag-height) * 0.5);
      font-weight: 600;
      line-height: 1;
      color: var(--muted-foreground);
    }
    &::after {
      content: "";
      position: absolute;
      inset: 0;
      background-image: var(--language-switcher-gloss, none), var(--language-switcher-flag, none);
      background-position: center;
      background-size: cover;
      background-repeat: no-repeat;
    }
    /* a flag in the dark is a little dimmer, so it does not glare beside the text */
    :where(.dark) &::after {
      filter: brightness(0.92) saturate(0.95);
    }
  }
  .language-switcher-icon {
    flex: none;
    width: 1rem;
    height: 1rem;
  }
  .language-switcher-label {
    white-space: nowrap;
  }
  /* -- The Combobox face ------------------------------------------ */
  .language-switcher-combobox {
    display: inline-block;
    & .combobox-trigger {
      width: auto;
      justify-content: flex-start;
      gap: 0.5rem;
    }
    & .combobox-value {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      flex: none;
      font-weight: 500;
    }
    /* a language is always chosen: no clear button, the chevron stays */
    & .combobox-clear {
      display: none !important;
    }
    &.combobox:has(.combobox-value) .combobox-trigger > .combobox-chevron {
      display: block;
    }
    & .combobox-content {
      width: max-content;
      min-width: max(anchor-size(width), 13rem);
      max-width: min(22rem, calc(100vw - 1rem));
      position-try-fallbacks:
        flip-inline,
        flip-block,
        flip-block flip-inline;
    }
    /* a short list needs no search box; it stays for the keys (arrows, Enter) */
    & .combobox-search[data-quiet] {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      border: 0;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }
  /* the list opens toward the start edge where the switcher sits at the end (a header's right side) */
  .language-switcher[data-align="end"] .combobox-content {
    left: auto;
    right: anchor(right);
  }
  .language-switcher-option {
    gap: 0.625rem;
    &[data-highlighted] .language-switcher-flag,
    &:hover .language-switcher-flag {
      scale: 1.08;
    }
  }
  .language-switcher-name {
    white-space: nowrap;
  }
  .language-switcher-alt {
    margin-inline-start: auto;
    padding-inline-start: 1rem;
    font-size: 0.75rem;
    color: var(--muted-foreground);
    white-space: nowrap;
    &[hidden] {
      display: none;
    }
    .language-switcher-option[data-highlighted] &,
    .language-switcher-option:hover & {
      color: inherit;
      opacity: 0.75;
    }
  }
  /* -- The Menu face: radio items in a Dropdown Menu ----------------- */
  .language-switcher[data-variant="menu"] {
    display: block;
  }
  .language-switcher-menu {
    display: contents;
  }
  .language-switcher-item {
    gap: 0.625rem;
    &[data-highlighted] .language-switcher-flag,
    &:hover .language-switcher-flag {
      scale: 1.08;
    }
    & .language-switcher-alt {
      color: var(--muted-foreground);
    }
  }
  /* -- The Swap face ---------------------------------------------- */
  .language-switcher-swap {
    & .swap-on,
    & .swap-off {
      gap: 0.375rem;
      padding-inline: 0.375rem;
    }
    & .language-switcher-label {
      font-weight: 500;
    }
  }
  /* -- Accessibility ---------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .language-switcher-flag {
      transition: none;
    }
    :is(.language-switcher-option, .language-switcher-item) .language-switcher-flag {
      scale: none !important;
    }
  }
  @media (prefers-contrast: more) {
    .language-switcher-flag {
      outline: 1px solid var(--foreground);
      outline-offset: -1px;
    }
    .language-switcher-alt {
      color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    /* the flag keeps its own colors; a system-color frame marks its edge */
    .language-switcher-flag {
      forced-color-adjust: none;
      outline: 1px solid CanvasText;
      outline-offset: -1px;
    }
  }
}

§JavaScript view file

The preseeded languages, the names (Intl.DisplayNames), the Combobox and Swap faces it writes, the State API and bind() for defuss-i18n.

// -- Language Switcher --------------------------------------------------------------------------------
// Picks the page's language from the languages it supports (data-languages: BCP 47 tags - "en de", "en-GB en-US
// pt-BR pt-PT"). It knows every language CLD3 detects (107 codes): the English name, the name in the language itself
// and the region whose flag stands for it - the names in any other language come from the browser's
// Intl.DisplayNames (CLDR), the preseeded names when the browser has none. A tag with a region is that dialect: its
// name says so ("British English") and its flag is the region's. Three faces, each of shipped components the runtime
// writes into the element: a Combobox (the default - a searchable list, each language in its own name and in the
// page's), a Swap (data-variant="swap" - one click moves on to the next language) and radio items for a Dropdown
// Menu (data-variant="menu"). data-display shows the
// language's name (label), its code (code) or the flag alone (icon). Flags are SVGs (flag-icons, MIT, pinned on
// jsDelivr - configure({ flags }) to self-host) in a shape, a scale and a finish of data-flag-shape / -size / -style;
// data-flag="none" leaves them out. bind(el, i18n) ties it to a defuss-i18n instance both ways.
//
// VERIFIED: (language-switcher.e2e) the Combobox lists only the supported languages, a pick changes the state and
// fires language-change, the Swap moves on with each click, and bind() follows and drives a defuss-i18n instance.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js -
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
/** default = the chosen language shown; open = the Combobox's list shown (the Swap has no list). */
const languageSwitcherStates = ['default', 'open'];
/** setState() configs per state. */
export interface LanguageSwitcherStateConfigs {
  /** The chosen language shown. */
  default: {
    /** the language: one of data-languages (a BCP 47 tag) */
    value?: string;
  };
  /** The list of languages shown (the Combobox face). */
  open: {
    /** the language: one of data-languages (a BCP 47 tag) */
    value?: string;
  };
}
/** What language-change carries. */
interface LanguageSwitcherChangeDetail {
  /** the language chosen now (a BCP 47 tag) */
  value: string;
  /** the language before */
  previous: string;
  /** its name in itself ("Deutsch") */
  name: string;
  /** its writing direction */
  dir: 'ltr' | 'rtl';
}
/** One language the switcher knows. */
export interface LanguageInfo {
  /** its CLD3 code (a BCP 47 tag; iw is Hebrew, he works too) */
  code: string;
  /** its name in English */
  english: string;
  /** its name in itself */
  native: string;
  /** the region whose flag stands for it (ISO 3166, lowercase; a subdivision like gb-wls); '' for none */
  region: string;
  /** its writing direction */
  dir: 'ltr' | 'rtl';
}
/** What a defuss-i18n instance (or any locale store of that shape) offers bind(). */
export interface LanguageSwitcherI18n {
  /** the locale shown now */
  readonly locale: string;
  /** switches the locale */
  setLocale(locale: string): unknown;
  /** follows locale changes; returns the function that stops it */
  subscribe?(listener: (snapshot: { locale: string }) => void): () => void;
}
/** configure()'s options. */
export interface LanguageSwitcherOptions {
  /** the flag URL pattern: {shape} becomes 4x3 or 1x1, {region} the region (default: flag-icons 7.2.3 on jsDelivr) */
  flags?: string;
}
// -- the languages CLD3 detects: code, English name, its own name, the flag's region ------------------------------
// VERIFIED: (github.com/google/cld3 README, 2026-11-09) the supported-languages table lists these 107 codes, script
// variants (-Latn) included; iw is the code CLD3 gives Hebrew. The regions are a convention for the flag only - a
// language is not a country, which is why every flag can be switched off or replaced per language.
const SEED = `af Afrikaans|Afrikaans|za
am Amharic|አማርኛ|et
ar Arabic|العربية|sa
bg Bulgarian|български|bg
bg-Latn Bulgarian (Latin)|bulgarski|bg
bn Bangla|বাংলা|bd
bs Bosnian|bosanski|ba
ca Catalan|català|es-ct
ceb Cebuano|Binisaya|ph
co Corsican|corsu|fr
cs Czech|čeština|cz
cy Welsh|Cymraeg|gb-wls
da Danish|dansk|dk
de German|Deutsch|de
el Greek|Ελληνικά|gr
el-Latn Greek (Latin)|Ellinika|gr
en English|English|gb
eo Esperanto|Esperanto|
es Spanish|español|es
et Estonian|eesti|ee
eu Basque|euskara|es-pv
fa Persian|فارسی|ir
fi Finnish|suomi|fi
fil Filipino|Filipino|ph
fr French|français|fr
fy Western Frisian|Frysk|nl
ga Irish|Gaeilge|ie
gd Scottish Gaelic|Gàidhlig|gb-sct
gl Galician|galego|es-ga
gu Gujarati|ગુજરાતી|in
ha Hausa|Hausa|ng
haw Hawaiian|ʻŌlelo Hawaiʻi|us
hi Hindi|हिन्दी|in
hi-Latn Hindi (Latin)|Hindi|in
hmn Hmong|Hmoob|la
hr Croatian|hrvatski|hr
ht Haitian Creole|Kreyòl ayisyen|ht
hu Hungarian|magyar|hu
hy Armenian|հայերեն|am
id Indonesian|Bahasa Indonesia|id
ig Igbo|Asụsụ Igbo|ng
is Icelandic|íslenska|is
it Italian|italiano|it
iw Hebrew|עברית|il
ja Japanese|日本語|jp
ja-Latn Japanese (Latin)|Nihongo|jp
jv Javanese|Basa Jawa|id
ka Georgian|ქართული|ge
kk Kazakh|қазақ тілі|kz
km Khmer|ខ្មែរ|kh
kn Kannada|ಕನ್ನಡ|in
ko Korean|한국어|kr
ku Kurdish|Kurdî|iq
ky Kyrgyz|кыргызча|kg
la Latin|Latina|va
lb Luxembourgish|Lëtzebuergesch|lu
lo Lao|ລາວ|la
lt Lithuanian|lietuvių|lt
lv Latvian|latviešu|lv
mg Malagasy|Malagasy|mg
mi Māori|Māori|nz
mk Macedonian|македонски|mk
ml Malayalam|മലയാളം|in
mn Mongolian|монгол|mn
mr Marathi|मराठी|in
ms Malay|Bahasa Melayu|my
mt Maltese|Malti|mt
my Burmese|မြန်မာ|mm
ne Nepali|नेपाली|np
nl Dutch|Nederlands|nl
no Norwegian|norsk|no
ny Nyanja|Chichewa|mw
pa Punjabi|ਪੰਜਾਬੀ|in
pl Polish|polski|pl
ps Pashto|پښتو|af
pt Portuguese|português|pt
ro Romanian|română|ro
ru Russian|русский|ru
ru-Latn Russian (Latin)|Russkiy|ru
sd Sindhi|سنڌي|pk
si Sinhala|සිංහල|lk
sk Slovak|slovenčina|sk
sl Slovenian|slovenščina|si
sm Samoan|Gagana Sāmoa|ws
sn Shona|chiShona|zw
so Somali|Soomaali|so
sq Albanian|shqip|al
sr Serbian|српски|rs
st Southern Sotho|Sesotho|ls
su Sundanese|Basa Sunda|id
sv Swedish|svenska|se
sw Swahili|Kiswahili|tz
ta Tamil|தமிழ்|in
te Telugu|తెలుగు|in
tg Tajik|тоҷикӣ|tj
th Thai|ไทย|th
tr Turkish|Türkçe|tr
uk Ukrainian|українська|ua
ur Urdu|اردو|pk
uz Uzbek|oʻzbekcha|uz
vi Vietnamese|Tiếng Việt|vn
xh Xhosa|isiXhosa|za
yi Yiddish|ייִדיש|il
yo Yoruba|Èdè Yorùbá|ng
zh Chinese|中文|cn
zh-Latn Chinese (Pinyin)|Zhōngwén|cn
zu Zulu|isiZulu|za`;
const RTL = new Set(['ar', 'fa', 'iw', 'he', 'ps', 'sd', 'ur', 'yi']);
const LANGUAGES: LanguageInfo[] = SEED.split('\n').map((line) => {
  const sp = line.indexOf(' ');
  const code = line.slice(0, sp);
  const [english, native, region] = line.slice(sp + 1).split('|');
  return { code, english, native, region, dir: RTL.has(code) ? 'rtl' : 'ltr' };
});
const BY_CODE = new Map(LANGUAGES.map((l) => [l.code.toLowerCase(), l]));
const ALIAS: Record<string, string> = { he: 'iw', nb: 'no', tl: 'fil' };
const settings: Required<LanguageSwitcherOptions> = {
  flags: 'https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/{shape}/{region}.svg',
};
const resolve = (t) => (typeof t === 'string' ? (dfDollar('#' + CSS.escape(t)).get(0) ?? dfDollar(t).get(0)) : t);
const esc = (v: unknown): string =>
  String(v ?? '').replace(
    /[&<>"']/g,
    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
  );
/** a tag's parts: the language, the script, the region (lowercase) */
function partsOf(tag: string): { language: string; script: string; region: string } {
  try {
    const l = new Intl.Locale(tag);
    return { language: l.language, script: l.script ?? '', region: (l.region ?? '').toLowerCase() };
  } catch {
    const [language = '', ...rest] = tag.split(/[-_]/);
    return {
      language: language.toLowerCase(),
      script: rest.find((p) => p.length === 4) ?? '',
      region: (rest.find((p) => p.length === 2) ?? '').toLowerCase(),
    };
  }
}
/** the preseeded entry of a tag: itself, its language + script, its language (aliases followed) */
function seedOf(tag: string): LanguageInfo | undefined {
  const { language, script } = partsOf(tag);
  const lang = ALIAS[language] ?? language;
  return (
    BY_CODE.get(tag.toLowerCase()) ??
    (script ? BY_CODE.get(`${lang}-${script}`.toLowerCase()) : undefined) ??
    BY_CODE.get(lang)
  );
}
const capitalize = (name: string, locale: string): string => {
  if (!name) return name;
  let first: string;
  try {
    first = name.charAt(0).toLocaleUpperCase(locale);
  } catch {
    first = name.charAt(0).toUpperCase();
  }
  return first + name.slice(1);
};
const displayCache = new Map<string, Intl.DisplayNames | null>();
function displayNames(locale: string, type: 'language' | 'region'): Intl.DisplayNames | null {
  const key = `${type}:${locale}`;
  if (!displayCache.has(key)) {
    let d: Intl.DisplayNames | null = null;
    try {
      d = new Intl.DisplayNames([locale], { type, fallback: 'none' });
      // a browser without the locale's names falls back to its own language - those are not the names asked for
      if (new Intl.Locale(d.resolvedOptions().locale).language !== partsOf(locale).language) d = null;
    } catch {
      d = null;
    }
    displayCache.set(key, d);
  }
  return displayCache.get(key) ?? null;
}
/** a CLDR name, or undefined - of() throws on a malformed code (`en_US`), which must not stop the switcher drawing
 *  VERIFIED: (bun, 2026-11-09) Intl.DisplayNames#of('en_US') throws RangeError "argument is not a language id" */
function cldrName(locale: string, type: 'language' | 'region', code: string): string | undefined {
  try {
    return displayNames(locale, type)?.of(code);
  } catch {
    return undefined;
  }
}
/** a language's name in a locale: the preseeded names of a CLD3 code in itself and in English (the same in every
 *  browser), the browser's CLDR names for every other pair, the preseeded ones (+ the region) when it has none */
function nameIn(tag: string, locale: string): string {
  const seed = seedOf(tag);
  const { region } = partsOf(tag);
  const self = partsOf(locale).language === partsOf(tag).language;
  // a preseeded code without a region named in itself or in English: the seed is the reference
  if (seed && !region && seed.code.toLowerCase() === tag.toLowerCase().replace(/^he$/, 'iw')) {
    if (self) return capitalize(seed.native, tag);
    if (partsOf(locale).language === 'en') return seed.english;
  }
  const n = cldrName(locale, 'language', tag.replace(/^iw\b/, 'he'));
  if (n && n.toLowerCase() !== tag.toLowerCase()) return capitalize(n, locale);
  const base = seed ? (self ? capitalize(seed.native, tag) : seed.english) : tag;
  if (!region) return base;
  const regionName = cldrName(locale, 'region', region.toUpperCase()) ?? region.toUpperCase();
  return `${base} (${regionName})`;
}
/** the region whose flag stands for a tag: data-flags' override, the tag's own region, else the seed's */
function regionFor(el: HTMLElement, tag: string): string {
  for (const pair of (el.dataset.flags || '').split(/\s+/).filter(Boolean)) {
    const [t, r] = pair.split(':');
    if (t && t.toLowerCase() === tag.toLowerCase()) return (r || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
  }
  return (partsOf(tag).region || seedOf(tag)?.region || '').replace(/[^a-z0-9-]/g, '');
}
// -- the markup the runtime writes ----------------------------------------------------------------------------
const languagesOf = (el: HTMLElement): string[] => {
  const list = (el.dataset.languages || 'en').split(/[\s,]+/).filter(Boolean);
  return list.length ? list : ['en'];
};
const valueOf = (el: HTMLElement, languages = languagesOf(el)): string => {
  const v = (el.dataset.value || '').toLowerCase();
  return languages.find((l) => l.toLowerCase() === v) ?? languages[0];
};
const nextOf = (el: HTMLElement, tag: string): string => {
  const languages = languagesOf(el);
  return languages[(languages.indexOf(tag) + 1) % languages.length];
};
const codeLabel = (tag: string): string => {
  const { language, region } = partsOf(tag);
  return (region ? `${language}-${region}` : language).toUpperCase();
};
const dirOf = (tag: string): 'ltr' | 'rtl' => (RTL.has(partsOf(tag).language) ? 'rtl' : 'ltr');
const idPart = (tag: string): string => tag.toLowerCase().replace(/[^a-z0-9-]/g, '-');
/** a flag: the SVG laid over the language's code (a missing or blocked flag shows the code) */
function flagHtml(el: HTMLElement, tag: string): string {
  if (el.dataset.flag === 'none') return '';
  const region = regionFor(el, tag);
  const shape = ['square', 'circle', 'squircle'].includes(el.dataset.flagShape || '') ? '1x1' : '4x3';
  const url = region ? settings.flags.replace('{shape}', shape).replace('{region}', region) : '';
  const image = region ? ` data-region="${esc(region)}" style="--language-switcher-flag: url('${esc(url)}')"` : '';
  return `<span class="language-switcher-flag" data-code="${esc(partsOf(tag).language.toUpperCase())}"${image} aria-hidden="true"></span>`;
}
const GLOBE =
  '<svg class="language-switcher-icon" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
const CHEVRON =
  '<svg class="combobox-chevron" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>';
/** what the trigger or a Swap face shows for a language: its flag (the globe without one) and its name or code */
function faceHtml(el: HTMLElement, tag: string): string {
  const display = el.dataset.display || 'label';
  const flag = flagHtml(el, tag) || (display === 'icon' ? GLOBE : '');
  const text =
    display === 'icon'
      ? ''
      : display === 'code'
        ? codeLabel(tag)
        : el.dataset.names === 'ui'
          ? nameIn(tag, valueOf(el))
          : nameIn(tag, tag);
  return `${flag}${text ? `<span class="language-switcher-label" lang="${esc(tag)}" dir="${dirOf(tag)}">${esc(text)}</span>` : ''}`;
}
/** the switcher's name: data-label, else the root's aria-label (translatable with defuss-i18n), else "Language" */
const labelOf = (el: HTMLElement): string => el.dataset.label || el.getAttribute('aria-label') || 'Language';
const ariaOf = (el: HTMLElement, tag: string): string => `${labelOf(el)}: ${nameIn(tag, tag)}`;
/** an option's two names, each with its language: in itself and in the language shown (data-names orders them) */
function namesOf(
  el: HTMLElement,
  tag: string,
  ui: string,
): { name: string; lang: string; alt: string; altLang: string } {
  const own = nameIn(tag, tag),
    shown = nameIn(tag, ui);
  if (el.dataset.names === 'native') return { name: own, lang: tag, alt: '', altLang: tag };
  if (el.dataset.names === 'ui') return { name: shown, lang: ui, alt: shown === own ? '' : own, altLang: tag };
  return { name: own, lang: tag, alt: shown === own ? '' : shown, altLang: ui };
}
const optionInner = (el: HTMLElement, tag: string, ui: string): string => {
  const n = namesOf(el, tag, ui);
  return `${flagHtml(el, tag)}<span class="language-switcher-name" lang="${esc(n.lang)}" dir="${dirOf(n.lang)}">${esc(n.name)}</span><span class="language-switcher-alt" lang="${esc(n.altLang)}" dir="${dirOf(n.altLang)}"${n.alt ? '' : ' hidden'}>${esc(n.alt)}</span>`;
};
/** the Combobox face: a trigger and a searchable list of the supported languages */
function comboboxHtml(el: HTMLElement, uid: string): string {
  const languages = languagesOf(el);
  const value = valueOf(el, languages);
  const search = el.dataset.search || 'Search languages...';
  const options = languages
    .map(
      (tag) =>
        `<div role="option" id="${uid}-${idPart(tag)}" class="combobox-item language-switcher-option" data-value="${esc(tag)}" aria-selected="${tag === value}">${optionInner(el, tag, value)}</div>`,
    )
    .join('');
  const icon = (el.dataset.display || 'label') === 'icon';
  return `<div class="combobox language-switcher-combobox" data-ce-chrome${el.dataset.size ? ` data-size="${esc(el.dataset.size)}"` : ''}>
<button type="button" class="btn combobox-trigger" data-variant="${esc(el.dataset.buttonVariant || 'ghost')}"${icon ? ' data-size="icon"' : ''} aria-haspopup="listbox" aria-expanded="false" aria-label="${esc(ariaOf(el, value))}" aria-controls="${uid}-popover"><span class="combobox-value">${faceHtml(el, value)}</span>${icon ? '' : CHEVRON}</button>
<div id="${uid}-popover" class="combobox-content language-switcher-content" popover>
<div class="combobox-search"${languages.length > 7 ? '' : ' data-quiet'}><input class="combobox-search-input" type="text" role="combobox" autocomplete="off" aria-expanded="true" aria-controls="${uid}-listbox" aria-activedescendant="" aria-autocomplete="list" placeholder="${esc(search)}" aria-label="${esc(search)}"></div>
<div id="${uid}-listbox" role="listbox" class="combobox-listbox" aria-label="${esc(labelOf(el))}"><div class="combobox-empty" hidden>${esc(el.dataset.empty || 'No language found.')}</div>${options}</div>
</div>
</div>`;
}
/** the Swap face: the language shown on one side, the next one on the other - a click turns it over */
function swapHtml(el: HTMLElement): string {
  const value = valueOf(el);
  return `<label class="swap language-switcher-swap"${el.dataset.swap === 'fade' ? '' : ` data-variant="${esc(el.dataset.swap || 'flip')}"`} data-ce-chrome${el.dataset.size ? ` data-size="${esc(el.dataset.size)}"` : ''}>
<input type="checkbox" role="button" aria-label="${esc(swapLabel(el, value))}">
<span class="swap-off">${faceHtml(el, value)}</span>
<span class="swap-on">${faceHtml(el, nextOf(el, value))}</span>
</label>`;
}
/** the Menu face: one radio item per language for a Dropdown Menu, which moves through them, checks the one picked
 *  and closes nothing - the root (role="group") is their group */
function menuHtml(el: HTMLElement): string {
  const value = valueOf(el);
  return `<div class="language-switcher-menu" data-ce-chrome>${languagesOf(el)
    .map(
      (tag) =>
        `<button type="button" class="dropdown-item dropdown-radio language-switcher-item" role="menuitemradio" tabindex="-1" aria-checked="${tag === value}" data-value="${esc(tag)}">${optionInner(el, tag, value)}</button>`,
    )
    .join('')}</div>`;
}
const swapLabel = (el: HTMLElement, value: string): string => {
  const next = nextOf(el, value);
  return `${ariaOf(el, value)} - ${el.dataset.next || 'next'}: ${nameIn(next, next)}`;
};
// -- drawing: the whole face when its attributes change, the value in place -------------------------------------
const STRUCTURE = [
  'languages',
  'variant',
  'display',
  'names',
  'flag',
  'flags',
  'flagShape',
  'size',
  'buttonVariant',
  'search',
  'empty',
  'next',
  'swap',
] as const;
const structureOf = (el: HTMLElement): string => STRUCTURE.map((k) => el.dataset[k] ?? '').join('|');
const chromeOf = (el: HTMLElement): HTMLElement | undefined =>
  dfDollar(el).children('[data-ce-chrome]').get(0) as HTMLElement | undefined;
/** draws the whole face the attributes ask for */
function draw(el: HTMLElement): void {
  const uid = el.id ? `${el.id}-ls` : (el._lsId ??= `language-switcher-${++sequence}`);
  const focused = el.contains(document.activeElement);
  dfDollar(el).children('[data-ce-chrome]').remove();
  const variant = el.dataset.variant;
  dfDollar(el).append(
    dfDollar(variant === 'swap' ? swapHtml(el) : variant === 'menu' ? menuHtml(el) : comboboxHtml(el, uid)),
  );
  el._lsStructure = structureOf(el);
  el._lsValue = valueOf(el);
  if (focused)
    (
      dfDollar(el).find('.combobox-trigger, .swap > input, .language-switcher-item[aria-checked="true"]').get(0) as
        | HTMLElement
        | undefined
    )?.focus();
}
let sequence = 0;
/** shows the value and the names without drawing the face again (focus, the open list and the Swap's side stay) */
function update(el: HTMLElement): void {
  if (el._lsStructure !== structureOf(el) || !chromeOf(el)) return draw(el);
  const value = valueOf(el);
  const changed = el._lsValue !== value;
  el._lsValue = value;
  const chrome = dfDollar(chromeOf(el));
  if (el.dataset.variant === 'swap') {
    const input = chrome.find(':scope > input').get(0) as HTMLInputElement;
    if (changed) {
      // the side showing gets the value, the side turned away the language after it
      const [shown, hidden] = input.checked ? ['.swap-on', '.swap-off'] : ['.swap-off', '.swap-on'];
      chrome.find(`:scope > ${shown}`).html(faceHtml(el, value));
      chrome.find(`:scope > ${hidden}`).html(faceHtml(el, nextOf(el, value)));
    }
    dfDollar(input).attr('aria-label', swapLabel(el, value));
    return;
  }
  if (el.dataset.variant === 'menu') {
    if (!changed) return;
    // the items stay (focus, the menu's highlight); the check and the names follow the language shown
    chrome.find('.language-switcher-item').each((_i, item: HTMLElement) => {
      const tag = item.dataset.value || '';
      dfDollar(item)
        .attr('aria-checked', String(tag === value))
        .html(optionInner(el, tag, value));
    });
    return;
  }
  chrome.find('.combobox-trigger').attr('aria-label', ariaOf(el, value));
  chrome.find('[role="listbox"]').attr('aria-label', labelOf(el));
  if (!changed) return;
  chrome.find('.combobox-value').html(faceHtml(el, value)).attr('data-placeholder', null);
  // the options stay (the Combobox holds them); their names follow the language shown
  chrome.find('.language-switcher-option').each((_i, option: HTMLElement) => {
    const tag = option.dataset.value || '';
    dfDollar(option)
      .attr('aria-selected', String(tag === value))
      .html(optionInner(el, tag, value));
  });
}
/** chooses a language of data-languages; announce = fire language-change */
function choose(el: HTMLElement, tag: string, announce = true): boolean {
  const languages = languagesOf(el);
  const next = languages.find((l) => l.toLowerCase() === String(tag).toLowerCase());
  if (!next) return false;
  const previous = valueOf(el, languages);
  if (el.dataset.value !== next) dfDollar(el).attr('data-value', next);
  update(el);
  if (announce && next !== previous)
    // Fires when the user (or choose()) picks another language: the one chosen, the one before, its own name and
    // its direction - bind() turns it into the i18n locale.
    el.dispatchEvent(
      new CustomEvent<LanguageSwitcherChangeDetail>('language-change', {
        bubbles: true,
        detail: { value: next, previous, name: nameIn(next, next), dir: dirOf(next) },
      }),
    );
  return true;
}
// -- the state ----------------------------------------------------------------------------------------------
const popoverOf = (el: HTMLElement): HTMLElement | undefined =>
  dfDollar(el).find(':scope > .language-switcher-combobox > .combobox-content').get(0) as HTMLElement | undefined;
const isOpen = (el: HTMLElement): boolean => {
  try {
    return !!popoverOf(el)?.matches(':popover-open');
  } catch {
    return false;
  }
};
/** The markup of a state: the language chosen is data-value. The list ('open') is the runtime's own chrome. */
function applyMarkup(el: HTMLElement, _name: string, config: { value?: string } | undefined) {
  const languages = languagesOf(el);
  const value = languages.find((l) => l.toLowerCase() === String(config?.value ?? '').toLowerCase());
  if (value) dfDollar(el).attr('data-value', value);
}
/** UI side of a state: the language, then the list shown (open) or hidden (default) */
function triggerStateChange(el: HTMLElement, name: string, config: { value?: string }) {
  if (config?.value) choose(el, config.value, false);
  const popover = popoverOf(el);
  if (!popover) return;
  if (name === 'open' && !isOpen(el)) (dfDollar(el).find('.combobox-trigger').get(0) as HTMLElement)?.click();
  else if (name !== 'open' && isOpen(el)) popover.hidePopover();
}
export const languageSwitcherApi = componentState({
  component: 'language-switcher',
  states: languageSwitcherStates,
  mergeConfig: true,
  apply: (el, state) => triggerStateChange(el, state.name, state.config as { value?: string }),
  // the user picks and opens the list without setState
  read: (el) => ({ name: isOpen(el) ? 'open' : 'default', config: { value: valueOf(el) } }),
  markup: (el, state) => applyMarkup(el, state.name, state.config as { value?: string }),
});
df$.languageSwitcherApi = languageSwitcherApi;
df$.languageSwitcherStates = languageSwitcherStates;
// -- init --------------------------------------------------------------------------------------------------
function init() {
  dfDollar('.language-switcher:not([data-init])').each((_i, el: HTMLElement) => {
    el.dataset.init = '';
    // the language shown is part of the markup from the start (render() writes it in every state)
    if (!el.dataset.value || valueOf(el) !== el.dataset.value) dfDollar(el).attr('data-value', valueOf(el));
    draw(el);
    // the Combobox: a pick is the language (a pick of the shown one changes nothing)
    dfDollar(el).on('combobox:change', (e: Event) => {
      const value = (e as CustomEvent<{ values: string[] }>).detail?.values?.[0];
      el._lsValue = '';
      if (!value || !choose(el, value)) update(el);
      languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) });
    });
    // the Swap: every click turns it over to the next language
    dfDollar(el).on('change', (e: Event) => {
      const input = e.target as HTMLInputElement;
      if (!input.matches('.language-switcher-swap > input') || input.closest('.language-switcher') !== el) return;
      choose(el, nextOf(el, valueOf(el)));
      languageSwitcherApi.commit(el, 'default', { value: valueOf(el) });
    });
    // the Menu: an item picked - through the Dropdown Menu (click, Enter, Space) or a click outside one
    const pick = (item: EventTarget | null | undefined) => {
      const it = item as HTMLElement | null;
      if (!it?.matches?.('.language-switcher-item') || it.closest('.language-switcher') !== el) return;
      el._lsValue = '';
      if (!choose(el, it.dataset.value || '')) update(el);
      languageSwitcherApi.commit(el, 'default', { value: valueOf(el) });
    };
    dfDollar(el).on('dropdown:select', (e: Event) => pick((e as CustomEvent<{ item: HTMLElement }>).detail?.item));
    dfDollar(el).on('click', (e: Event) => {
      const item = (e.target as HTMLElement | null)?.closest?.('.language-switcher-item');
      if (item && !item.closest('[role="menu"]')) pick(item);
    });
    // a checkbox in the role of a button: Enter turns it over too
    dfDollar(el).on('keydown', (e: KeyboardEvent) => {
      const input = e.target as HTMLInputElement;
      if (e.key !== 'Enter' || !input.matches?.('.language-switcher-swap > input')) return;
      e.preventDefault();
      input.click();
    });
    // the list opening and closing is the state
    el.addEventListener(
      'toggle',
      () => languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) }),
      true,
    );
    // attributes the page changes (a translation writes data-label, a page adds a language) show at once
    new MutationObserver(() => update(el)).observe(el, { attributes: true, attributeFilter: OBSERVED });
    bindComponent(el, languageSwitcherApi, { name: 'default', config: { value: valueOf(el) } });
  });
}
const OBSERVED = [
  'data-value',
  'data-label',
  'aria-label',
  ...STRUCTURE.map((k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())),
];
// -- df$.shadcn.languageSwitcher: the imperative surface --------------------------------------------------------
df$.languageSwitcher = {
  /**
   * Every language the switcher knows: the 107 CLD3 codes with their English name, their own name, the region of
   * their flag and their direction.
   * @returns a copy of the list
   */
  languages: (): LanguageInfo[] => LANGUAGES.map((l) => ({ ...l })),
  /**
   * A language's name in a locale - the preseeded names for a CLD3 code in itself and in English, the browser's
   * (CLDR) names for every other pair, the preseeded ones when the browser has none.
   * @param tag - the language (a BCP 47 tag: "de", "de-AT", "zh-Latn")
   * @param locale - the language to name it in (default: the language itself)
   * @returns the name, capitalized for the start of a label
   */
  name: (tag: string, locale?: string): string => nameIn(tag, locale ?? tag),
  /**
   * The region whose flag stands for a language: its own region, else the preseeded one.
   * @param tag - the language (a BCP 47 tag)
   * @returns the region (ISO 3166, lowercase; '' for a language without one, like Esperanto)
   */
  region: (tag: string): string => partsOf(tag).region || seedOf(tag)?.region || '',
  /**
   * Chooses a language of data-languages as a click would: the face changes and language-change fires.
   * @param target - the .language-switcher element, its id or a selector
   * @param tag - the language
   * @returns false when the switcher does not offer it
   */
  choose: (target: string | HTMLElement, tag: string): boolean => {
    const el = resolve(target);
    if (!el || !choose(el, tag)) return false;
    languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) });
    return true;
  },
  /**
   * Ties the switcher to a defuss-i18n instance both ways: a pick sets its locale, a locale set elsewhere moves the
   * switcher (quietly - no language-change).
   * @param target - the .language-switcher element, its id or a selector
   * @param i18n - the instance (createI18n) - anything with locale, setLocale() and subscribe()
   * @returns a function that unties them
   */
  bind: (target: string | HTMLElement, i18n: LanguageSwitcherI18n): (() => void) => {
    const el = resolve(target);
    if (!el || !i18n) return () => {};
    const follow = (locale: string) => {
      if (locale && locale.toLowerCase() !== valueOf(el).toLowerCase() && choose(el, locale, false))
        languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) });
    };
    follow(i18n.locale);
    const onChange = (e: Event) => {
      const value = (e as CustomEvent<LanguageSwitcherChangeDetail>).detail.value;
      if (value !== i18n.locale) i18n.setLocale(value);
    };
    el.addEventListener('language-change', onChange);
    const stop = i18n.subscribe?.((snapshot) => follow(snapshot.locale));
    return () => {
      el.removeEventListener('language-change', onChange);
      stop?.();
    };
  },
  /**
   * Where the flags come from - set it before the first switcher draws (to self-host the SVGs).
   * @param options - the flag URL pattern
   */
  configure: (options: LanguageSwitcherOptions): void => {
    if (options?.flags) settings.flags = options.flags;
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub