@lilydesignsystem/svelte-locale-picker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LocalePicker.svelte +547 -0
- package/dist/LocalePicker.svelte.d.ts +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/locales.d.ts +6 -0
- package/dist/locales.js +483 -0
- package/index.md +571 -0
- package/package.json +48 -0
package/index.md
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
# LocalePicker (Svelte helper)
|
|
2
|
+
|
|
3
|
+
A reusable, headless Svelte 5 locale picker — an **icon button that
|
|
4
|
+
opens a WAI-ARIA APG listbox** — that applies the chosen locale to the
|
|
5
|
+
document root via `lang` and `dir`, with optional `localStorage`
|
|
6
|
+
persistence and `navigator.languages` detection.
|
|
7
|
+
|
|
8
|
+
For the full contract see [spec/index.md](./spec/index.md) — it is the single source
|
|
9
|
+
of truth for the API, behaviour, and tests.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
This directory is published as a folder-style import; consumers either
|
|
14
|
+
copy it into their project or wire it as a workspace dependency. The
|
|
15
|
+
only runtime dependency is `svelte` ≥ 5.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import LocalePicker from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or via the barrel (recommended; gives you the typed helpers too):
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import LocalePicker, {
|
|
25
|
+
bcp47LocaleTag,
|
|
26
|
+
isRtlLocale,
|
|
27
|
+
localeName,
|
|
28
|
+
type Props,
|
|
29
|
+
type ChildArgs,
|
|
30
|
+
} from "./lily-design-system-svelte-locale-picker";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
Render the select with a `label` and the list of locales your app
|
|
36
|
+
supports. The select writes `lang` and `dir` onto `<html>` so your
|
|
37
|
+
i18n library, your CSS (`html[dir="rtl"]`), and assistive technology
|
|
38
|
+
all see the change.
|
|
39
|
+
|
|
40
|
+
```svelte
|
|
41
|
+
<script lang="ts">
|
|
42
|
+
import LocalePicker, {
|
|
43
|
+
bcp47LocaleTag,
|
|
44
|
+
localeName,
|
|
45
|
+
} from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
46
|
+
|
|
47
|
+
let locale = $state("");
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<LocalePicker
|
|
51
|
+
label="Language"
|
|
52
|
+
locales={["en", "en_US", "fr", "fr_CA", "ar", "he"]}
|
|
53
|
+
bind:value={locale}
|
|
54
|
+
storageKey="lily-locale"
|
|
55
|
+
detectFromNavigator
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<p class="locale-picker-status" aria-live="polite">
|
|
59
|
+
Active language:
|
|
60
|
+
<span lang={bcp47LocaleTag(locale)}>{localeName(locale)}</span>
|
|
61
|
+
</p>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You also need to **style the listbox** — the package ships zero CSS,
|
|
65
|
+
and an unpositioned popup renders in normal flow and shoves the page
|
|
66
|
+
down when it opens. Use logical properties, since this control flips
|
|
67
|
+
the page to RTL. See
|
|
68
|
+
[docs/styling.md § Positioning the listbox](./docs/styling.md#positioning-the-listbox).
|
|
69
|
+
|
|
70
|
+
The status line is recommended, though no longer strictly compensatory.
|
|
71
|
+
The listbox marks the active option with `aria-selected="true"`, so a
|
|
72
|
+
screen-reader user who opens the control does hear which locale is
|
|
73
|
+
current. But the _closed_ control shows only a glyph — and unlike a
|
|
74
|
+
theme select, the active locale is not something a user can infer by
|
|
75
|
+
looking, unless they can already read the page, which is the one thing
|
|
76
|
+
this control cannot assume. `aria-live="polite"` announces mutations
|
|
77
|
+
only, so it stays silent on first paint and speaks once per change, and
|
|
78
|
+
the `lang` on the `<span>` keeps the locale name pronounced in its own
|
|
79
|
+
language. Full reasoning and when to omit it:
|
|
80
|
+
[docs/accessibility.md](./docs/accessibility.md#the-status-region).
|
|
81
|
+
|
|
82
|
+
> The example above wraps the name in `lang` while showing
|
|
83
|
+
> `localeName`, which returns the **English** name — so drop the `lang`
|
|
84
|
+
> unless you are supplying endonyms via `localeLabels`. See the note in
|
|
85
|
+
> [docs/accessibility.md](./docs/accessibility.md#the-status-region).
|
|
86
|
+
|
|
87
|
+
When the user picks `ar`, the component:
|
|
88
|
+
|
|
89
|
+
- sets `lang="ar"` on `<html>`,
|
|
90
|
+
- sets `dir="rtl"` on `<html>` (auto-detected from the locale),
|
|
91
|
+
- writes `"ar"` to `localStorage["lily-locale"]`,
|
|
92
|
+
- fires `onChange("ar")` if provided.
|
|
93
|
+
|
|
94
|
+
The select does NOT translate strings — that is the consumer's
|
|
95
|
+
i18n library (e.g. `svelte-i18n`, Paraglide, Inlang, Tolgee, raw
|
|
96
|
+
`Intl.*`). Wire the bindable `value` or `onChange` to your library so
|
|
97
|
+
it loads the right messages.
|
|
98
|
+
|
|
99
|
+
## BCP 47 normalisation
|
|
100
|
+
|
|
101
|
+
Language tags follow **BCP 47** (RFC 5646). The `lang` attribute on
|
|
102
|
+
HTML elements must use hyphens, while many applications carry locale
|
|
103
|
+
identifiers with underscores (`en_US`, `zh_Hant_TW`). The select
|
|
104
|
+
accepts whichever form you prefer in the `locales` array and converts
|
|
105
|
+
to the hyphen form when writing to the DOM. The bindable `value`
|
|
106
|
+
preserves your original form, so round-trips are lossless.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
bcp47LocaleTag("en_US"); // "en-US"
|
|
110
|
+
bcp47LocaleTag("zh_Hant_TW"); // "zh-Hant-TW"
|
|
111
|
+
bcp47LocaleTag("en"); // "en"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
References:
|
|
115
|
+
|
|
116
|
+
- W3C — [Language tags in HTML and XML](https://www.w3.org/International/articles/language-tags/)
|
|
117
|
+
- IETF — [RFC 5646 (BCP 47), Tags for Identifying Languages](https://www.rfc-editor.org/rfc/rfc5646)
|
|
118
|
+
- IANA — [Language Subtag Registry (registry file)](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)
|
|
119
|
+
|
|
120
|
+
## RTL auto-detection
|
|
121
|
+
|
|
122
|
+
`isRtlLocale(locale)` returns `true` for any locale whose base
|
|
123
|
+
language is one of `ar`, `arc`, `ckb`, `dv`, `fa`, `he`, `iw`, `ji`,
|
|
124
|
+
`ks`, `ku`, `mzn`, `ps`, `sd`, `ug`, `ur`, `yi`, OR whose script
|
|
125
|
+
subtag is one of `Arab`, `Hebr`, `Thaa`, `Syrc`, `Nkoo`, `Mong`,
|
|
126
|
+
`Adlm`.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
isRtlLocale("ar"); // true
|
|
130
|
+
isRtlLocale("he_IL"); // true
|
|
131
|
+
isRtlLocale("uz_Arab_AF"); // true (script subtag)
|
|
132
|
+
isRtlLocale("en"); // false
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Pass `applyDir={false}` if you want full control of `dir` yourself.
|
|
136
|
+
|
|
137
|
+
## Examples
|
|
138
|
+
|
|
139
|
+
### Rendered markup
|
|
140
|
+
|
|
141
|
+
```svelte
|
|
142
|
+
<script lang="ts">
|
|
143
|
+
import LocalePicker from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
144
|
+
let locale = $state("en");
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<LocalePicker label="Language" locales={["en", "cy"]} bind:value={locale} />
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Renders:
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<div class="locale-picker">
|
|
154
|
+
<input type="hidden" name="locale" value="en" />
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class="locale-picker-button"
|
|
158
|
+
aria-label="Language"
|
|
159
|
+
aria-haspopup="listbox"
|
|
160
|
+
aria-expanded="false"
|
|
161
|
+
aria-controls="locale-picker-1-list"
|
|
162
|
+
>
|
|
163
|
+
<svg class="locale-picker-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M2 8h12"/><path d="M8 2c2.2 0 4 2.7 4 6s-1.8 6-4 6-4-2.7-4-6 1.8-6 4-6z"/></svg>
|
|
164
|
+
</button>
|
|
165
|
+
<ul
|
|
166
|
+
class="locale-picker-list"
|
|
167
|
+
id="locale-picker-1-list"
|
|
168
|
+
role="listbox"
|
|
169
|
+
aria-label="Language"
|
|
170
|
+
tabindex="-1"
|
|
171
|
+
hidden
|
|
172
|
+
>
|
|
173
|
+
<li
|
|
174
|
+
class="locale-picker-option"
|
|
175
|
+
id="locale-picker-1-option-0"
|
|
176
|
+
role="option"
|
|
177
|
+
aria-selected="true"
|
|
178
|
+
lang="en"
|
|
179
|
+
>
|
|
180
|
+
English
|
|
181
|
+
</li>
|
|
182
|
+
<li
|
|
183
|
+
class="locale-picker-option"
|
|
184
|
+
id="locale-picker-1-option-1"
|
|
185
|
+
role="option"
|
|
186
|
+
aria-selected="false"
|
|
187
|
+
lang="cy"
|
|
188
|
+
>
|
|
189
|
+
Welsh
|
|
190
|
+
</li>
|
|
191
|
+
</ul>
|
|
192
|
+
</div>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Each locale option carries its own `lang` attribute so a screen reader
|
|
196
|
+
pronounces "Cymraeg" with a Welsh voice (WCAG 3.1.2, Language of
|
|
197
|
+
Parts). The button and the list carry none — they are in whatever
|
|
198
|
+
language you wrote `label` in.
|
|
199
|
+
|
|
200
|
+
The glyph is U+1F310 GLOBE WITH MERIDIANS followed by **U+FE0E
|
|
201
|
+
VARIATION SELECTOR-15**, which forces monochrome text presentation so
|
|
202
|
+
the control matches `theme-picker`'s `◑` instead of rendering as a blue
|
|
203
|
+
colour emoji. It is `aria-hidden`; the accessible name comes from
|
|
204
|
+
`label`.
|
|
205
|
+
|
|
206
|
+
The hidden input keeps the control working inside a `<form>`, carrying
|
|
207
|
+
the consumer-form code.
|
|
208
|
+
|
|
209
|
+
### Why an icon button
|
|
210
|
+
|
|
211
|
+
The closed control costs one glyph of page width whether you offer
|
|
212
|
+
three locales or all 436 in `locales.tsv`. A native `<select>` is as
|
|
213
|
+
wide as its longest option, or truncates it.
|
|
214
|
+
|
|
215
|
+
This shape has three real costs — an icon-only control's name rests
|
|
216
|
+
entirely on `aria-label` (which is itself written in _one_ language,
|
|
217
|
+
for the one control a user reaches when they cannot read the page); a
|
|
218
|
+
hand-rolled listbox has weaker assistive-technology support than a
|
|
219
|
+
native `<select>`; and the glyph is a font-dependent character that may
|
|
220
|
+
substitute, render in colour, or fail to render. They are set out in
|
|
221
|
+
full, with mitigations, in
|
|
222
|
+
[docs/accessibility.md](./docs/accessibility.md). **For some audiences
|
|
223
|
+
a native `<select>` is the better choice** — read that page before
|
|
224
|
+
adopting this helper in an accessibility-critical or public-service
|
|
225
|
+
context.
|
|
226
|
+
|
|
227
|
+
### Keyboard
|
|
228
|
+
|
|
229
|
+
Follows the WAI-ARIA APG
|
|
230
|
+
[Listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/).
|
|
231
|
+
Every key is implemented by the component — none of it comes from the
|
|
232
|
+
platform.
|
|
233
|
+
|
|
234
|
+
On the **button**:
|
|
235
|
+
|
|
236
|
+
| Key | Action |
|
|
237
|
+
| -------------------------------- | ------------------------------------- |
|
|
238
|
+
| `Enter` / `Space` / `Arrow Down` | Open with the selected option active. |
|
|
239
|
+
| `Arrow Up` | Open with the **last** option active. |
|
|
240
|
+
|
|
241
|
+
On the **listbox** (focus moves there on open):
|
|
242
|
+
|
|
243
|
+
| Key | Action |
|
|
244
|
+
| ------------------------- | -------------------------------------------------- |
|
|
245
|
+
| `Arrow Down` / `Arrow Up` | Move the active option; **clamps**, does not wrap. |
|
|
246
|
+
| `Home` / `End` | Jump to the first / last option. |
|
|
247
|
+
| `Enter` / `Space` | Select, apply, close, refocus the button. |
|
|
248
|
+
| `Escape` | Close and refocus **without** changing the locale. |
|
|
249
|
+
| `PageUp` / `PageDown` | Move the active option by ten; clamps. |
|
|
250
|
+
| `Tab` | Close; focus lands on the button so the default Tab proceeds from the picker's position. |
|
|
251
|
+
| Printable character | Typeahead over the labels; 500 ms buffer. A single character advances to the next match and repeats cycle; differing characters refine. |
|
|
252
|
+
|
|
253
|
+
Clicking an option selects it; clicking outside or moving focus out of
|
|
254
|
+
the root closes the listbox.
|
|
255
|
+
|
|
256
|
+
Typeahead matches the **label**, and default labels are endonyms, so
|
|
257
|
+
a user types "Fra" for "Français". With `localeLabels` overrides the
|
|
258
|
+
consumer's spelling wins. Choose deliberately for long lists.
|
|
259
|
+
|
|
260
|
+
### Pretty labels for the option text
|
|
261
|
+
|
|
262
|
+
By default each option shows the language's **endonym** — its own name
|
|
263
|
+
for itself, "Cymraeg" not "Welsh" — via `Intl.DisplayNames` asked in
|
|
264
|
+
that language (exported as `localeEndonym`). The user who needs a
|
|
265
|
+
language menu is the one who cannot read the page's language, and the
|
|
266
|
+
exonym means nothing to them. The English names from `locales.tsv` and
|
|
267
|
+
the raw code remain as fallbacks for runtimes without the data.
|
|
268
|
+
Override per-code with `localeLabels`:
|
|
269
|
+
|
|
270
|
+
```svelte
|
|
271
|
+
<LocalePicker
|
|
272
|
+
label="Langue"
|
|
273
|
+
locales={["en", "fr", "ar"]}
|
|
274
|
+
localeLabels={{ en: "English", fr: "Français", ar: "العربية" }}
|
|
275
|
+
bind:value={locale}
|
|
276
|
+
/>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Each option carries a `lang="…"` attribute so each one is
|
|
280
|
+
announced in its own language. Prefer endonyms — the person who needs
|
|
281
|
+
this control is the person who cannot read your default language.
|
|
282
|
+
|
|
283
|
+
### Customising the button
|
|
284
|
+
|
|
285
|
+
The `children` snippet **replaces the glyph inside the trigger
|
|
286
|
+
button**. It receives `{ value, open, labelFor }` and does **not**
|
|
287
|
+
render the options — the listbox is component-owned.
|
|
288
|
+
|
|
289
|
+
Pairing the glyph with the active locale's endonym is the strongest
|
|
290
|
+
mitigation for the icon-only naming tradeoff:
|
|
291
|
+
|
|
292
|
+
```svelte
|
|
293
|
+
<script lang="ts">
|
|
294
|
+
import LocalePicker, {
|
|
295
|
+
bcp47LocaleTag,
|
|
296
|
+
isRtlLocale,
|
|
297
|
+
} from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
298
|
+
|
|
299
|
+
let locale = $state("en");
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
<LocalePicker
|
|
303
|
+
label="Language"
|
|
304
|
+
locales={["en", "fr", "ar"]}
|
|
305
|
+
localeLabels={{ en: "English", fr: "Français", ar: "العربية" }}
|
|
306
|
+
bind:value={locale}
|
|
307
|
+
>
|
|
308
|
+
{#snippet children({ value, open, labelFor })}
|
|
309
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
|
|
310
|
+
<span
|
|
311
|
+
class="locale-picker-text"
|
|
312
|
+
lang={bcp47LocaleTag(value)}
|
|
313
|
+
dir={isRtlLocale(value) ? "rtl" : "ltr"}
|
|
314
|
+
>
|
|
315
|
+
{labelFor(value)}
|
|
316
|
+
</span>
|
|
317
|
+
<span aria-hidden="true">{open ? "▴" : "▾"}</span>
|
|
318
|
+
{/snippet}
|
|
319
|
+
</LocalePicker>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The `lang` on the span is only correct because the labels are endonyms —
|
|
323
|
+
which the built-in labels now are. The component applies the same rule
|
|
324
|
+
to its own options: `lang` is set only when the label is the derived
|
|
325
|
+
endonym, never on consumer labels of unknown language.
|
|
326
|
+
|
|
327
|
+
The snippet's output lives inside a `<button>`, so it must not contain
|
|
328
|
+
interactive elements. The pre-listbox patterns built on the old
|
|
329
|
+
`ChildArgs` — a custom `<select>`, a radio group, a button group, a
|
|
330
|
+
`<datalist>` combobox — are no longer possible; read `value` and drive
|
|
331
|
+
your own controls instead. See
|
|
332
|
+
[docs/custom-rendering.md](./docs/custom-rendering.md).
|
|
333
|
+
|
|
334
|
+
### Wiring an i18n library
|
|
335
|
+
|
|
336
|
+
```svelte
|
|
337
|
+
<script lang="ts">
|
|
338
|
+
import LocalePicker from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
339
|
+
import { locale as i18nLocale } from "svelte-i18n"; // or Paraglide, Inlang, …
|
|
340
|
+
|
|
341
|
+
let current = $state("");
|
|
342
|
+
</script>
|
|
343
|
+
|
|
344
|
+
<LocalePicker
|
|
345
|
+
label="Language"
|
|
346
|
+
locales={["en", "fr", "ar"]}
|
|
347
|
+
bind:value={current}
|
|
348
|
+
detectFromNavigator
|
|
349
|
+
storageKey="app-locale"
|
|
350
|
+
onChange={(code) => i18nLocale.set(code)}
|
|
351
|
+
/>
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Server-resolved initial value (SSR)
|
|
355
|
+
|
|
356
|
+
For flicker-free first paint, resolve the locale on the server (from a
|
|
357
|
+
cookie or `Accept-Language`) and pass it as `value`:
|
|
358
|
+
|
|
359
|
+
```svelte
|
|
360
|
+
<script lang="ts">
|
|
361
|
+
let { initialLocale }: { initialLocale: string } = $props();
|
|
362
|
+
let locale = $state(initialLocale);
|
|
363
|
+
</script>
|
|
364
|
+
|
|
365
|
+
<LocalePicker
|
|
366
|
+
label="Language"
|
|
367
|
+
locales={["en", "fr", "ar"]}
|
|
368
|
+
value={locale}
|
|
369
|
+
bind:value={locale}
|
|
370
|
+
/>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
During SSR the component renders the button and the (hidden) listbox,
|
|
374
|
+
with `aria-selected` and the hidden input reflecting the supplied
|
|
375
|
+
`value`, and the document already arrives with the correct `lang`
|
|
376
|
+
attribute on `<html>` — which is what prevents the flicker. Option ids
|
|
377
|
+
come from an incrementing module counter, so server and client agree
|
|
378
|
+
and hydration matches.
|
|
379
|
+
|
|
380
|
+
### Render into a scoped target instead of `<html>`
|
|
381
|
+
|
|
382
|
+
Set `target` to a specific element when you want the locale scoped to a
|
|
383
|
+
region (e.g. a multilingual side panel):
|
|
384
|
+
|
|
385
|
+
```svelte
|
|
386
|
+
<script lang="ts">
|
|
387
|
+
import LocalePicker from "./lily-design-system-svelte-locale-picker/LocalePicker.svelte";
|
|
388
|
+
let region: HTMLElement | null = $state(null);
|
|
389
|
+
let panelLocale = $state("fr");
|
|
390
|
+
</script>
|
|
391
|
+
|
|
392
|
+
<section bind:this={region}>
|
|
393
|
+
<p>This panel switches language independently of the page.</p>
|
|
394
|
+
<LocalePicker
|
|
395
|
+
label="Panel language"
|
|
396
|
+
locales={["en", "fr", "ar"]}
|
|
397
|
+
target={region}
|
|
398
|
+
bind:value={panelLocale}
|
|
399
|
+
/>
|
|
400
|
+
</section>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`<html>` stays in the page's default locale; the section gets the
|
|
404
|
+
chosen one.
|
|
405
|
+
|
|
406
|
+
## Built-in locale data
|
|
407
|
+
|
|
408
|
+
`locales.ts` ships the 436 codes from `locales.tsv` mapped to their
|
|
409
|
+
English names. Since the endonym change these are a **fallback** — the
|
|
410
|
+
default label is `localeEndonym(code)`, and the table is consulted only
|
|
411
|
+
when the runtime lacks `Intl.DisplayNames` data for a code. You can also import
|
|
412
|
+
the data directly:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import {
|
|
416
|
+
defaultLocaleLabels,
|
|
417
|
+
RTL_LANGUAGE_TAGS,
|
|
418
|
+
RTL_SCRIPT_SUBTAGS,
|
|
419
|
+
} from "./lily-design-system-svelte-locale-picker";
|
|
420
|
+
|
|
421
|
+
console.log(defaultLocaleLabels["en_US"]); // "English (United States)"
|
|
422
|
+
console.log(RTL_LANGUAGE_TAGS.has("ar")); // true
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Props
|
|
426
|
+
|
|
427
|
+
See [spec/index.md §4](./spec/index.md#4-public-api) for the full table.
|
|
428
|
+
|
|
429
|
+
Required props: `label`, `locales`.
|
|
430
|
+
|
|
431
|
+
Common optional props: `value` (bindable), `defaultValue`,
|
|
432
|
+
`storageKey`, `detectFromNavigator`, `localeLabels`, `applyDir`,
|
|
433
|
+
`target`, `onChange`, `class`, `name`, `children`.
|
|
434
|
+
|
|
435
|
+
**There is no `placeholder` prop.** It was removed along with the
|
|
436
|
+
native `<select>` it belonged to. Field-by-field reference:
|
|
437
|
+
[docs/props-reference.md](./docs/props-reference.md).
|
|
438
|
+
|
|
439
|
+
## Class hooks
|
|
440
|
+
|
|
441
|
+
`.locale-picker` (root `<div>`), `.locale-picker-button` (the trigger),
|
|
442
|
+
`.locale-picker-icon` (the glyph span), `.locale-picker-list` (the
|
|
443
|
+
`<ul role="listbox">`), `.locale-picker-option` (each
|
|
444
|
+
`<li role="option">`). Plus `[data-active]` for the keyboard cursor and
|
|
445
|
+
`[aria-selected]` for the applied locale — style both.
|
|
446
|
+
|
|
447
|
+
The `.locale-picker-placeholder` hook is gone with the placeholder
|
|
448
|
+
option.
|
|
449
|
+
|
|
450
|
+
The package ships zero CSS, so **you must position the listbox**, using
|
|
451
|
+
logical properties so it survives the RTL switch this control performs.
|
|
452
|
+
See [docs/styling.md](./docs/styling.md).
|
|
453
|
+
|
|
454
|
+
## Accessibility
|
|
455
|
+
|
|
456
|
+
- Built to the WAI-ARIA APG **Listbox** pattern: a `<button
|
|
457
|
+
aria-haspopup="listbox">` controlling a `<ul role="listbox">` whose
|
|
458
|
+
active option is tracked with `aria-activedescendant`.
|
|
459
|
+
- `aria-label={label}` names both the button and the listbox.
|
|
460
|
+
- The full keyboard contract is implemented by the component — see
|
|
461
|
+
[Keyboard](#keyboard).
|
|
462
|
+
- Each `<li role="option">` carries `lang="…"` so its name is
|
|
463
|
+
pronounced in the right language (WCAG 3.1.2, Language of Parts).
|
|
464
|
+
- The document root carries `lang` and (by default) `dir` so the page
|
|
465
|
+
satisfies WCAG 3.1.1 (Language of Page) and bidi text/layout
|
|
466
|
+
inverts correctly for RTL locales.
|
|
467
|
+
- The active state is exposed four ways: `aria-selected` on the option,
|
|
468
|
+
`lang` on the target, the hidden input's value, and the `value`
|
|
469
|
+
binding. No colour-only meaning.
|
|
470
|
+
- Choosing a locale returns focus to the trigger button and does not
|
|
471
|
+
navigate — WCAG 3.2.2 (On Input).
|
|
472
|
+
|
|
473
|
+
**Three tradeoffs, stated plainly:**
|
|
474
|
+
|
|
475
|
+
1. The button is icon-only, so its accessible name rests **entirely**
|
|
476
|
+
on `aria-label` — and `aria-label` is written in _one_ language.
|
|
477
|
+
This is the control a user reaches for precisely when they cannot
|
|
478
|
+
read the page, so the circularity is real. Pairing the glyph with
|
|
479
|
+
the active locale's endonym via `children` is the strongest
|
|
480
|
+
mitigation.
|
|
481
|
+
2. A hand-rolled listbox has **weaker assistive-technology support**
|
|
482
|
+
than a native `<select>` — particularly on mobile, where a native
|
|
483
|
+
select opens the OS picker. For some audiences, and public-service
|
|
484
|
+
audiences especially, a plain `<select aria-label>` with one
|
|
485
|
+
`<option lang>` per locale is genuinely the better choice; it is
|
|
486
|
+
about fifteen lines, and this package's exported pure helpers still
|
|
487
|
+
do the logic.
|
|
488
|
+
3. The glyph is a **font-dependent character**. VS15 requests
|
|
489
|
+
monochrome presentation but cannot guarantee it, and on a device
|
|
490
|
+
with no covering font the button renders empty or as a "tofu" box.
|
|
491
|
+
|
|
492
|
+
Each has mitigations. Read
|
|
493
|
+
[docs/accessibility.md](./docs/accessibility.md) before adopting this
|
|
494
|
+
helper in an accessibility-critical context.
|
|
495
|
+
|
|
496
|
+
## Tests
|
|
497
|
+
|
|
498
|
+
`pnpm test` under a vitest + jsdom + `@testing-library/svelte` setup
|
|
499
|
+
exercises every numbered acceptance clause in
|
|
500
|
+
[spec/index.md §7](./spec/index.md#7-testing-acceptance-criteria) — 27
|
|
501
|
+
clauses covering the markup contract, the pure helpers, locale
|
|
502
|
+
application, initial-value resolution, spread + custom children, and
|
|
503
|
+
the APG keyboard contract, plus four untagged extras for
|
|
504
|
+
case-insensitive RTL detection and the navigator matcher.
|
|
505
|
+
|
|
506
|
+
## Files in this directory
|
|
507
|
+
|
|
508
|
+
| File | Purpose |
|
|
509
|
+
| ----------------------- | ----------------------------------------------------------------- |
|
|
510
|
+
| `spec/index.md` | Single source of truth — API, behaviour, tests. |
|
|
511
|
+
| `LocalePicker.svelte` | The component implementation. |
|
|
512
|
+
| `LocalePicker.test.ts` | vitest suite covering every spec §7 item. |
|
|
513
|
+
| `locales.ts` | Built-in code → English-name map and RTL sets. |
|
|
514
|
+
| `locales.tsv` | Canonical 436-row source for `locales.ts`. |
|
|
515
|
+
| `index.ts` | Re-export barrel. |
|
|
516
|
+
| `index.md` | This file — quick start + worked examples. |
|
|
517
|
+
| `docs/` | Deep-dive guides — see [Documentation](#documentation). |
|
|
518
|
+
| `examples/` | Runnable Svelte 5 example components — see [Examples](#examples). |
|
|
519
|
+
|
|
520
|
+
## Documentation
|
|
521
|
+
|
|
522
|
+
Shared with `theme-picker` (same topics, written for this helper):
|
|
523
|
+
|
|
524
|
+
| Guide | Covers |
|
|
525
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------- |
|
|
526
|
+
| [docs/props-reference.md](./docs/props-reference.md) | Field-by-field reference for every prop, with rationale. |
|
|
527
|
+
| [docs/styling.md](./docs/styling.md) | Class and attribute hooks, positioning the listbox, RTL-safe CSS. |
|
|
528
|
+
| [docs/custom-rendering.md](./docs/custom-rendering.md) | The `children` snippet — replacing the button's glyph. |
|
|
529
|
+
| [docs/recipes.md](./docs/recipes.md) | Short solutions to adjacent problems. |
|
|
530
|
+
| [docs/troubleshooting.md](./docs/troubleshooting.md) | Symptoms, root causes, fixes. |
|
|
531
|
+
| [docs/accessibility.md](./docs/accessibility.md) | APG listbox contract, the three tradeoffs, screen-reader matrix. |
|
|
532
|
+
| [docs/ssr.md](./docs/ssr.md) | Cookie, URL-prefix, Accept-Language, streaming SSR, FOUC avoidance. |
|
|
533
|
+
|
|
534
|
+
Specific to locale-picker (no `theme-picker` counterpart):
|
|
535
|
+
|
|
536
|
+
| Guide | Covers |
|
|
537
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
|
538
|
+
| [docs/concepts.md](./docs/concepts.md) | Mental model, lifecycle diagram, why the defaults are what they are. |
|
|
539
|
+
| [docs/bcp47.md](./docs/bcp47.md) | Language-tag syntax (RFC 5646), IANA registry, subtag composition. |
|
|
540
|
+
| [docs/rtl.md](./docs/rtl.md) | What's auto-detected, what `dir="rtl"` actually changes, CSS tips. |
|
|
541
|
+
| [docs/i18n-integration.md](./docs/i18n-integration.md) | Wiring svelte-i18n, Paraglide, Tolgee, raw `Intl.*`, SvelteKit URL strategies. |
|
|
542
|
+
|
|
543
|
+
`theme-picker`'s `preloading.md` has no counterpart here — it is about
|
|
544
|
+
stylesheet preloading, which this helper does not do.
|
|
545
|
+
|
|
546
|
+
## Examples
|
|
547
|
+
|
|
548
|
+
Each file in `examples/` is a complete, runnable Svelte 5 component
|
|
549
|
+
you can copy into your project.
|
|
550
|
+
|
|
551
|
+
| Example | Demonstrates |
|
|
552
|
+
| ------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
553
|
+
| [basic.svelte](./examples/basic.svelte) | The default rendering, plus the `.locale-picker-status` live region. |
|
|
554
|
+
| [custom-rendering.svelte](./examples/custom-rendering.svelte) | `children` snippet — globe + the active locale's endonym + caret. |
|
|
555
|
+
| [many-locales.svelte](./examples/many-locales.svelte) | A 23-locale list in a one-glyph control; typeahead and scrolling. |
|
|
556
|
+
| [persistence.svelte](./examples/persistence.svelte) | `storageKey` plus `detectFromNavigator` on first visit. |
|
|
557
|
+
| [rtl-demo.svelte](./examples/rtl-demo.svelte) | Live RTL preview — Arabic, Hebrew, Persian, Urdu, Pashto. |
|
|
558
|
+
| [nhs-style.svelte](./examples/nhs-style.svelte) | NHS UK-style utility banner with endonyms and a `class` hook. |
|
|
559
|
+
| [with-svelte-i18n.svelte](./examples/with-svelte-i18n.svelte) | Binding to svelte-i18n's `locale` store. |
|
|
560
|
+
| [with-paraglide.svelte](./examples/with-paraglide.svelte) | Driving Paraglide JS's `setLocale()` from `onChange`. |
|
|
561
|
+
| [ssr-cookie.svelte](./examples/ssr-cookie.svelte) | SvelteKit cookie-based SSR — no flash of default locale. |
|
|
562
|
+
| [scoped-target.svelte](./examples/scoped-target.svelte) | Multiple per-region selects, each scoped to its own panel. |
|
|
563
|
+
|
|
564
|
+
These were previously numbered `01-radios`, `02-select`, `03-buttons`,
|
|
565
|
+
… — names left over from a radio-group rendering the package has not
|
|
566
|
+
had for some time. The mapping is recorded in
|
|
567
|
+
[examples/README.md](./examples/README.md#renamed-from-the-radio-group-era).
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
Lily™ and Lily Design System™ are trademarks.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lilydesignsystem/svelte-locale-picker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": "=26"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lily Design System Svelte 5 locale picker: an icon button opening an APG listbox that sets lang and dir on the document. Headless, SSR-safe, no CSS.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"svelte": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"svelte": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"index.md",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"prepublishOnly": "cd .. && npm run build"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"svelte": "^5.0.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"lily",
|
|
31
|
+
"design",
|
|
32
|
+
"system",
|
|
33
|
+
"svelte",
|
|
34
|
+
"locale",
|
|
35
|
+
"picker"
|
|
36
|
+
],
|
|
37
|
+
"author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
|
|
38
|
+
"license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/LilyDesignSystem/lily-design-system-svelte-helpers.git",
|
|
42
|
+
"directory": "lily-design-system-svelte-locale-picker"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://lilydesignsystem.com/",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
|
|
47
|
+
}
|
|
48
|
+
}
|