@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
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import {
|
|
4
|
+
defaultLocaleLabels,
|
|
5
|
+
RTL_LANGUAGE_TAGS,
|
|
6
|
+
RTL_SCRIPT_SUBTAGS,
|
|
7
|
+
} from "./locales.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default button icon: a bundled SVG (globe outline), not a Unicode
|
|
11
|
+
* character. Reversed 2026-09-16 from the font-dependent-glyph
|
|
12
|
+
* convention (was U+1F310 GLOBE WITH MERIDIANS + U+FE0E, exported as
|
|
13
|
+
* `GLOBE_WITH_MERIDIANS` — removed, not renamed). The old glyph needed
|
|
14
|
+
* VS15 to force text presentation and still risked the colour-emoji
|
|
15
|
+
* font on stacks that ignore the selector; a bundled outline SVG has
|
|
16
|
+
* no such risk and renders identically everywhere, matching the other
|
|
17
|
+
* four picker icons as one monochrome family. Override via `children`,
|
|
18
|
+
* same as before.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Arguments passed to a custom `children` snippet (the button glyph). */
|
|
22
|
+
export type ChildArgs = {
|
|
23
|
+
/** Currently selected locale code (consumer form, not BCP 47-normalised). */
|
|
24
|
+
value: string;
|
|
25
|
+
/** Is the listbox open? */
|
|
26
|
+
open: boolean;
|
|
27
|
+
/** Resolve a locale code to its display label. */
|
|
28
|
+
labelFor: (locale: string) => string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Public props for LocalePicker. See `spec/index.md` §4 for the contract. */
|
|
32
|
+
export type Props = {
|
|
33
|
+
/** Accessible name for the button and the listbox. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Available locale codes. */
|
|
36
|
+
locales: string[];
|
|
37
|
+
/** Currently selected locale code. Two-way bindable. */
|
|
38
|
+
value?: string;
|
|
39
|
+
/** Initial locale when nothing else is supplied. */
|
|
40
|
+
defaultValue?: string;
|
|
41
|
+
/** If set, persist the selection to localStorage under this key. */
|
|
42
|
+
storageKey?: string;
|
|
43
|
+
/** Resolve `navigator.languages` to a supported locale on first visit. */
|
|
44
|
+
detectFromNavigator?: boolean;
|
|
45
|
+
/** `name` of the hidden input that carries the value in a form. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** Element that receives `lang` and `dir`. Defaults to document.documentElement. */
|
|
48
|
+
target?: HTMLElement | null;
|
|
49
|
+
/** If false, the select only writes `lang` and never touches `dir`. */
|
|
50
|
+
applyDir?: boolean;
|
|
51
|
+
/** Optional pretty labels per locale code. */
|
|
52
|
+
localeLabels?: Record<string, string>;
|
|
53
|
+
/** Replaces the default globe icon inside the button. */
|
|
54
|
+
children?: Snippet<[ChildArgs]>;
|
|
55
|
+
/** Called after the control applies a new locale. */
|
|
56
|
+
onChange?: (locale: string) => void;
|
|
57
|
+
/** Extra CSS class on the root. */
|
|
58
|
+
class?: string;
|
|
59
|
+
/** Spread props onto the root element. */
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------
|
|
64
|
+
// Pure helpers (exported so consumers can reuse them)
|
|
65
|
+
// ---------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** Convert a locale code to its BCP 47 hyphen form. */
|
|
68
|
+
export function bcp47LocaleTag(locale: string): string {
|
|
69
|
+
return locale.replace(/_/g, "-");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Detect whether a locale is right-to-left. See spec/index.md §5.6. */
|
|
73
|
+
export function isRtlLocale(locale: string): boolean {
|
|
74
|
+
if (!locale) return false;
|
|
75
|
+
const parts = locale.split(/[-_]/);
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (RTL_SCRIPT_SUBTAGS.has(part.toLowerCase())) return true;
|
|
78
|
+
}
|
|
79
|
+
const base = parts[0]?.toLowerCase() ?? "";
|
|
80
|
+
return RTL_LANGUAGE_TAGS.has(base);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a locale code to its English name via the built-in table. */
|
|
84
|
+
export function localeName(locale: string): string {
|
|
85
|
+
return defaultLocaleLabels[locale] ?? locale;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The language's own name for itself — "de" → "Deutsch", "cy" →
|
|
90
|
+
* "Cymraeg" — from `Intl.DisplayNames` asked *in that language*.
|
|
91
|
+
*
|
|
92
|
+
* Endonyms are the right default for a language menu: the user who
|
|
93
|
+
* needs it most is the one lost in a UI that is not in their
|
|
94
|
+
* language, and they recognise "Cymraeg" where "Welsh" means
|
|
95
|
+
* nothing to them. Deterministic (no `navigator` dependency), so
|
|
96
|
+
* the server and the client render the same label. Returns "" when
|
|
97
|
+
* the runtime has no data — some runtimes echo the tag back instead
|
|
98
|
+
* of failing, and an echo is not a name.
|
|
99
|
+
*/
|
|
100
|
+
export function localeEndonym(locale: string): string {
|
|
101
|
+
try {
|
|
102
|
+
const tag = bcp47LocaleTag(locale);
|
|
103
|
+
const dn = new Intl.DisplayNames([tag], { type: "language" });
|
|
104
|
+
const found = dn.of(tag) ?? "";
|
|
105
|
+
return found && found.toLowerCase() !== tag.toLowerCase()
|
|
106
|
+
? found
|
|
107
|
+
: "";
|
|
108
|
+
} catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Re-export the built-in label table and RTL sets for convenience. */
|
|
114
|
+
export { defaultLocaleLabels, RTL_LANGUAGE_TAGS, RTL_SCRIPT_SUBTAGS };
|
|
115
|
+
|
|
116
|
+
/** Opportunistic Intl.DisplayNames lookup; never throws. */
|
|
117
|
+
function intlDisplayName(locale: string): string {
|
|
118
|
+
try {
|
|
119
|
+
const env =
|
|
120
|
+
typeof navigator !== "undefined" && navigator.language
|
|
121
|
+
? navigator.language
|
|
122
|
+
: "en";
|
|
123
|
+
const dn = new Intl.DisplayNames([env], { type: "language" });
|
|
124
|
+
return dn.of(bcp47LocaleTag(locale)) ?? "";
|
|
125
|
+
} catch {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Match a navigator preference against a supported-locales list. */
|
|
131
|
+
export function matchNavigatorLanguage(
|
|
132
|
+
navLangs: readonly string[],
|
|
133
|
+
locales: readonly string[],
|
|
134
|
+
): string | "" {
|
|
135
|
+
const lc = (s: string) => s.toLowerCase().replace(/_/g, "-");
|
|
136
|
+
const localesLc = locales.map(lc);
|
|
137
|
+
for (const raw of navLangs) {
|
|
138
|
+
const nav = lc(raw);
|
|
139
|
+
|
|
140
|
+
// 1. Exact match (treating - and _ as equivalent).
|
|
141
|
+
const exactIndex = localesLc.indexOf(nav);
|
|
142
|
+
if (exactIndex !== -1) return locales[exactIndex];
|
|
143
|
+
|
|
144
|
+
// 2. Language-only match: pick the first locale whose
|
|
145
|
+
// base language matches the navigator's base language.
|
|
146
|
+
const navBase = nav.split("-")[0];
|
|
147
|
+
for (let i = 0; i < locales.length; i++) {
|
|
148
|
+
const base = localesLc[i].split("-")[0];
|
|
149
|
+
if (base === navBase) return locales[i];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let uid = 0;
|
|
156
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
157
|
+
export function nextLocalePickerId(): string {
|
|
158
|
+
uid += 1;
|
|
159
|
+
return `locale-picker-${uid}`;
|
|
160
|
+
}
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<script lang="ts">
|
|
164
|
+
let {
|
|
165
|
+
class: className = "",
|
|
166
|
+
label,
|
|
167
|
+
locales,
|
|
168
|
+
value = $bindable(""),
|
|
169
|
+
defaultValue,
|
|
170
|
+
storageKey,
|
|
171
|
+
detectFromNavigator = false,
|
|
172
|
+
name = "locale",
|
|
173
|
+
target,
|
|
174
|
+
applyDir = true,
|
|
175
|
+
localeLabels = {},
|
|
176
|
+
children,
|
|
177
|
+
onChange,
|
|
178
|
+
...restProps
|
|
179
|
+
}: Props = $props();
|
|
180
|
+
|
|
181
|
+
const baseId = nextLocalePickerId();
|
|
182
|
+
const listId = `${baseId}-list`;
|
|
183
|
+
const optionId = (i: number) => `${baseId}-option-${i}`;
|
|
184
|
+
|
|
185
|
+
let open = $state(false);
|
|
186
|
+
let activeIndex = $state(-1);
|
|
187
|
+
let buttonEl: HTMLButtonElement | undefined = $state();
|
|
188
|
+
let listEl: HTMLUListElement | undefined = $state();
|
|
189
|
+
let rootEl: HTMLDivElement | undefined = $state();
|
|
190
|
+
|
|
191
|
+
// Typeahead buffer: APG listbox behaviour. Reset after a pause.
|
|
192
|
+
let typeahead = "";
|
|
193
|
+
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
194
|
+
|
|
195
|
+
function labelFor(locale: string): string {
|
|
196
|
+
if (locale in localeLabels) return localeLabels[locale];
|
|
197
|
+
// Endonym first: a language menu names each language in itself,
|
|
198
|
+
// because the user who needs the menu is the one who cannot read
|
|
199
|
+
// the page's language. The English table and the environment
|
|
200
|
+
// lookup are fallbacks for runtimes without DisplayNames data.
|
|
201
|
+
const endonym = localeEndonym(locale);
|
|
202
|
+
if (endonym) return endonym;
|
|
203
|
+
if (locale in defaultLocaleLabels) return defaultLocaleLabels[locale];
|
|
204
|
+
const intl = intlDisplayName(locale);
|
|
205
|
+
if (intl) return intl;
|
|
206
|
+
return locale;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The `lang` attribute for one option — a claim about the language
|
|
211
|
+
* of the option's TEXT, made only when the text is the endonym we
|
|
212
|
+
* derived ourselves. A consumer label or the English fallback is in
|
|
213
|
+
* whatever language the consumer's UI speaks, and claiming otherwise
|
|
214
|
+
* sends a screen reader's speech engine to the wrong voice: the
|
|
215
|
+
* English word "Arabic" read out by an Arabic synthesizer.
|
|
216
|
+
*/
|
|
217
|
+
function optionLang(locale: string): string | undefined {
|
|
218
|
+
if (locale in localeLabels) return undefined;
|
|
219
|
+
return localeEndonym(locale) ? bcp47LocaleTag(locale) : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The code the DOM currently carries. Applying is idempotent: the
|
|
223
|
+
// effect below can run for reasons other than a locale change, and
|
|
224
|
+
// re-applying would re-fire `onChange`. A consumer whose onChange
|
|
225
|
+
// writes reactive state then re-enters this effect, and Svelte stops
|
|
226
|
+
// updating the component altogether (effect_update_depth_exceeded) —
|
|
227
|
+
// the listbox freezes mid-open with a stale aria-expanded. Guarding
|
|
228
|
+
// here also matches the spec: other prop changes are not retroactive.
|
|
229
|
+
let appliedValue = "";
|
|
230
|
+
|
|
231
|
+
function applyLocale(code: string): void {
|
|
232
|
+
if (typeof document === "undefined" || !code) return;
|
|
233
|
+
if (code === appliedValue) return;
|
|
234
|
+
appliedValue = code;
|
|
235
|
+
const root = target ?? document.documentElement;
|
|
236
|
+
root.setAttribute("lang", bcp47LocaleTag(code));
|
|
237
|
+
if (applyDir) {
|
|
238
|
+
root.setAttribute("dir", isRtlLocale(code) ? "rtl" : "ltr");
|
|
239
|
+
}
|
|
240
|
+
if (storageKey) {
|
|
241
|
+
try {
|
|
242
|
+
localStorage.setItem(storageKey, code);
|
|
243
|
+
} catch {
|
|
244
|
+
// ignore quota / privacy errors
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
onChange?.(code);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function setLocale(code: string): void {
|
|
251
|
+
value = code;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------
|
|
255
|
+
// Open / close
|
|
256
|
+
// ---------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function openList(startIndex?: number): void {
|
|
259
|
+
const selected = locales.indexOf(value);
|
|
260
|
+
// An empty list has no option to activate; -1 keeps
|
|
261
|
+
// aria-activedescendant off rather than pointing at an id that
|
|
262
|
+
// does not exist.
|
|
263
|
+
activeIndex =
|
|
264
|
+
locales.length === 0
|
|
265
|
+
? -1
|
|
266
|
+
: (startIndex ?? (selected >= 0 ? selected : 0));
|
|
267
|
+
open = true;
|
|
268
|
+
// Focus moves to the listbox; the active option is conveyed via
|
|
269
|
+
// aria-activedescendant, per the APG listbox pattern. preventScroll
|
|
270
|
+
// stops the browser's default scroll-into-view: the listbox is
|
|
271
|
+
// positioned by CSS (see AGENTS/theme.md), and without a consumer
|
|
272
|
+
// override for a right-edge header the box can render partly
|
|
273
|
+
// off-screen, and focusing it then auto-scrolled the whole page --
|
|
274
|
+
// which reads as the page jumping sideways the instant the picker
|
|
275
|
+
// opens.
|
|
276
|
+
queueMicrotask(() => {
|
|
277
|
+
listEl?.focus({ preventScroll: true });
|
|
278
|
+
scrollActiveIntoView();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function closeList(refocus = true): void {
|
|
283
|
+
if (!open) return;
|
|
284
|
+
open = false;
|
|
285
|
+
activeIndex = -1;
|
|
286
|
+
if (refocus) queueMicrotask(() => buttonEl?.focus({ preventScroll: true }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function choose(index: number): void {
|
|
290
|
+
const code = locales[index];
|
|
291
|
+
if (code) setLocale(code);
|
|
292
|
+
closeList();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function scrollActiveIntoView(): void {
|
|
296
|
+
if (activeIndex < 0 || !listEl) return;
|
|
297
|
+
// getElementById, not a `#id` selector: ids here are generated and
|
|
298
|
+
// contain nothing needing escaping, and `CSS` is absent entirely in
|
|
299
|
+
// jsdom — `CSS.escape` there throws inside the keydown handler,
|
|
300
|
+
// after activeIndex is already assigned, so the suite stays green
|
|
301
|
+
// while this path never actually runs.
|
|
302
|
+
const el = document.getElementById(optionId(activeIndex));
|
|
303
|
+
// Guard the METHOD, not just the element: jsdom implements no
|
|
304
|
+
// scrollIntoView, so `el?.scrollIntoView(...)` throws once `el`
|
|
305
|
+
// exists — and it throws after activeIndex is already assigned,
|
|
306
|
+
// which is why the suite stayed green while this path never ran.
|
|
307
|
+
el?.scrollIntoView?.({ block: "nearest" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function moveActive(delta: number): void {
|
|
311
|
+
if (locales.length === 0) return;
|
|
312
|
+
const next = Math.min(Math.max(activeIndex + delta, 0), locales.length - 1);
|
|
313
|
+
activeIndex = next;
|
|
314
|
+
scrollActiveIntoView();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runTypeahead(char: string): void {
|
|
318
|
+
const lower = char.toLowerCase();
|
|
319
|
+
// APG listbox typeahead: a single character moves to the NEXT
|
|
320
|
+
// option starting with it, and repeating that character keeps
|
|
321
|
+
// cycling. Only a buffer of differing characters refines the
|
|
322
|
+
// match, and that buffer stays anchored on the active option.
|
|
323
|
+
const sameCharRun =
|
|
324
|
+
typeahead === "" || [...typeahead].every((c) => c === lower);
|
|
325
|
+
typeahead += lower;
|
|
326
|
+
clearTimeout(typeaheadTimer);
|
|
327
|
+
typeaheadTimer = setTimeout(() => (typeahead = ""), 500);
|
|
328
|
+
const query = sameCharRun ? lower : typeahead;
|
|
329
|
+
const anchor = activeIndex < 0 ? 0 : activeIndex;
|
|
330
|
+
const start = sameCharRun ? anchor + 1 : anchor;
|
|
331
|
+
// Search forward, wrapping once — typeahead wraps even though the
|
|
332
|
+
// arrows clamp, or options above the cursor would be untypable.
|
|
333
|
+
for (let n = 0; n < locales.length; n++) {
|
|
334
|
+
const i = (start + n) % locales.length;
|
|
335
|
+
if (labelFor(locales[i]).toLowerCase().startsWith(query)) {
|
|
336
|
+
activeIndex = i;
|
|
337
|
+
scrollActiveIntoView();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function onButtonKeydown(event: KeyboardEvent): void {
|
|
344
|
+
switch (event.key) {
|
|
345
|
+
case "ArrowDown":
|
|
346
|
+
case "Enter":
|
|
347
|
+
case " ":
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
openList();
|
|
350
|
+
break;
|
|
351
|
+
case "ArrowUp":
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
openList(locales.length - 1);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function onListKeydown(event: KeyboardEvent): void {
|
|
359
|
+
switch (event.key) {
|
|
360
|
+
case "ArrowDown":
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
moveActive(1);
|
|
363
|
+
break;
|
|
364
|
+
case "ArrowUp":
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
moveActive(-1);
|
|
367
|
+
break;
|
|
368
|
+
case "Home":
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
activeIndex = 0;
|
|
371
|
+
scrollActiveIntoView();
|
|
372
|
+
break;
|
|
373
|
+
case "End":
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
activeIndex = locales.length - 1;
|
|
376
|
+
scrollActiveIntoView();
|
|
377
|
+
break;
|
|
378
|
+
case "Enter":
|
|
379
|
+
case " ":
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
if (activeIndex >= 0) choose(activeIndex);
|
|
382
|
+
break;
|
|
383
|
+
case "Escape":
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
closeList();
|
|
386
|
+
break;
|
|
387
|
+
case "PageUp":
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
moveActive(-10);
|
|
390
|
+
break;
|
|
391
|
+
case "PageDown":
|
|
392
|
+
// ±10, clamped: an APG-optional key for long locale lists.
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
moveActive(10);
|
|
395
|
+
break;
|
|
396
|
+
case "Tab":
|
|
397
|
+
// Tab moves on — but focus goes to the button FIRST,
|
|
398
|
+
// without cancelling the key. Hiding the focused list
|
|
399
|
+
// drops focus to <body>, and the browser then computes
|
|
400
|
+
// the default Tab move from the top of the document, so
|
|
401
|
+
// tabbing out of an open picker teleported the user to
|
|
402
|
+
// the page's first tab stop. From the button, the default
|
|
403
|
+
// Tab lands exactly where leaving the picker should.
|
|
404
|
+
buttonEl?.focus?.({ preventScroll: true });
|
|
405
|
+
closeList(false);
|
|
406
|
+
break;
|
|
407
|
+
default:
|
|
408
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
409
|
+
runTypeahead(event.key);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function onRootFocusOut(event: FocusEvent): void {
|
|
415
|
+
const next = event.relatedTarget as Node | null;
|
|
416
|
+
if (next && rootEl?.contains(next)) return;
|
|
417
|
+
closeList(false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------
|
|
421
|
+
// Initial value resolution + apply (unchanged from the select era)
|
|
422
|
+
// ---------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
let initialised = false;
|
|
425
|
+
|
|
426
|
+
$effect(() => {
|
|
427
|
+
const current = value;
|
|
428
|
+
|
|
429
|
+
if (!initialised) {
|
|
430
|
+
initialised = true;
|
|
431
|
+
let initial = current;
|
|
432
|
+
|
|
433
|
+
if (!initial && storageKey) {
|
|
434
|
+
try {
|
|
435
|
+
initial = localStorage.getItem(storageKey) ?? "";
|
|
436
|
+
} catch {
|
|
437
|
+
// ignore privacy errors
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!initial && detectFromNavigator && typeof navigator !== "undefined") {
|
|
442
|
+
const navLangs =
|
|
443
|
+
navigator.languages && navigator.languages.length > 0
|
|
444
|
+
? Array.from(navigator.languages)
|
|
445
|
+
: navigator.language
|
|
446
|
+
? [navigator.language]
|
|
447
|
+
: [];
|
|
448
|
+
initial = matchNavigatorLanguage(navLangs, locales);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!initial) {
|
|
452
|
+
initial =
|
|
453
|
+
defaultValue ??
|
|
454
|
+
(locales.includes("en") ? "en" : locales[0]) ??
|
|
455
|
+
"";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (initial && initial !== current) {
|
|
459
|
+
value = initial;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (current) applyLocale(current);
|
|
465
|
+
});
|
|
466
|
+
</script>
|
|
467
|
+
|
|
468
|
+
<svelte:document
|
|
469
|
+
onclick={(event) => {
|
|
470
|
+
if (!open) return;
|
|
471
|
+
const t = event.target as Node | null;
|
|
472
|
+
if (t && rootEl && !rootEl.contains(t)) closeList(false);
|
|
473
|
+
}}
|
|
474
|
+
/>
|
|
475
|
+
|
|
476
|
+
<div
|
|
477
|
+
bind:this={rootEl}
|
|
478
|
+
class={`locale-picker ${className}`.trim()}
|
|
479
|
+
onfocusout={onRootFocusOut}
|
|
480
|
+
{...restProps}
|
|
481
|
+
>
|
|
482
|
+
<input type="hidden" {name} {value} />
|
|
483
|
+
|
|
484
|
+
<button
|
|
485
|
+
bind:this={buttonEl}
|
|
486
|
+
type="button"
|
|
487
|
+
class="locale-picker-button"
|
|
488
|
+
aria-label={label}
|
|
489
|
+
aria-haspopup="listbox"
|
|
490
|
+
aria-expanded={open}
|
|
491
|
+
aria-controls={listId}
|
|
492
|
+
onclick={() => (open ? closeList() : openList())}
|
|
493
|
+
onkeydown={onButtonKeydown}
|
|
494
|
+
>
|
|
495
|
+
{#if children}
|
|
496
|
+
{@render children({ value: value ?? "", open, labelFor })}
|
|
497
|
+
{:else}
|
|
498
|
+
<svg
|
|
499
|
+
class="locale-picker-icon"
|
|
500
|
+
viewBox="0 0 16 16"
|
|
501
|
+
width="1.05rem"
|
|
502
|
+
height="1.05rem"
|
|
503
|
+
aria-hidden="true"
|
|
504
|
+
fill="none"
|
|
505
|
+
stroke="currentColor"
|
|
506
|
+
stroke-width="1.6"
|
|
507
|
+
stroke-linecap="round"
|
|
508
|
+
stroke-linejoin="round"
|
|
509
|
+
>
|
|
510
|
+
<circle cx="8" cy="8" r="6" />
|
|
511
|
+
<path d="M2 8h12" />
|
|
512
|
+
<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" />
|
|
513
|
+
</svg>
|
|
514
|
+
{/if}
|
|
515
|
+
</button>
|
|
516
|
+
|
|
517
|
+
<ul
|
|
518
|
+
bind:this={listEl}
|
|
519
|
+
class="locale-picker-list"
|
|
520
|
+
id={listId}
|
|
521
|
+
role="listbox"
|
|
522
|
+
aria-label={label}
|
|
523
|
+
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
|
|
524
|
+
tabindex="-1"
|
|
525
|
+
hidden={!open}
|
|
526
|
+
onkeydown={onListKeydown}
|
|
527
|
+
>
|
|
528
|
+
{#each locales as locale, i (locale)}
|
|
529
|
+
<!-- The option's keyboard interaction lives on the listbox
|
|
530
|
+
(aria-activedescendant pattern): the list is the focused
|
|
531
|
+
element and its keydown handler operates the options, so a
|
|
532
|
+
per-option key handler would be wrong, not missing. -->
|
|
533
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
534
|
+
<li
|
|
535
|
+
class="locale-picker-option"
|
|
536
|
+
id={optionId(i)}
|
|
537
|
+
role="option"
|
|
538
|
+
aria-selected={locale === value}
|
|
539
|
+
data-active={i === activeIndex ? "" : undefined}
|
|
540
|
+
lang={optionLang(locale)}
|
|
541
|
+
onclick={() => choose(i)}
|
|
542
|
+
>
|
|
543
|
+
{labelFor(locale)}
|
|
544
|
+
</li>
|
|
545
|
+
{/each}
|
|
546
|
+
</ul>
|
|
547
|
+
</div>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import { defaultLocaleLabels, RTL_LANGUAGE_TAGS, RTL_SCRIPT_SUBTAGS } from "./locales.js";
|
|
3
|
+
/**
|
|
4
|
+
* Default button icon: a bundled SVG (globe outline), not a Unicode
|
|
5
|
+
* character. Reversed 2026-09-16 from the font-dependent-glyph
|
|
6
|
+
* convention (was U+1F310 GLOBE WITH MERIDIANS + U+FE0E, exported as
|
|
7
|
+
* `GLOBE_WITH_MERIDIANS` — removed, not renamed). The old glyph needed
|
|
8
|
+
* VS15 to force text presentation and still risked the colour-emoji
|
|
9
|
+
* font on stacks that ignore the selector; a bundled outline SVG has
|
|
10
|
+
* no such risk and renders identically everywhere, matching the other
|
|
11
|
+
* four picker icons as one monochrome family. Override via `children`,
|
|
12
|
+
* same as before.
|
|
13
|
+
*/
|
|
14
|
+
/** Arguments passed to a custom `children` snippet (the button glyph). */
|
|
15
|
+
export type ChildArgs = {
|
|
16
|
+
/** Currently selected locale code (consumer form, not BCP 47-normalised). */
|
|
17
|
+
value: string;
|
|
18
|
+
/** Is the listbox open? */
|
|
19
|
+
open: boolean;
|
|
20
|
+
/** Resolve a locale code to its display label. */
|
|
21
|
+
labelFor: (locale: string) => string;
|
|
22
|
+
};
|
|
23
|
+
/** Public props for LocalePicker. See `spec/index.md` §4 for the contract. */
|
|
24
|
+
export type Props = {
|
|
25
|
+
/** Accessible name for the button and the listbox. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Available locale codes. */
|
|
28
|
+
locales: string[];
|
|
29
|
+
/** Currently selected locale code. Two-way bindable. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Initial locale when nothing else is supplied. */
|
|
32
|
+
defaultValue?: string;
|
|
33
|
+
/** If set, persist the selection to localStorage under this key. */
|
|
34
|
+
storageKey?: string;
|
|
35
|
+
/** Resolve `navigator.languages` to a supported locale on first visit. */
|
|
36
|
+
detectFromNavigator?: boolean;
|
|
37
|
+
/** `name` of the hidden input that carries the value in a form. */
|
|
38
|
+
name?: string;
|
|
39
|
+
/** Element that receives `lang` and `dir`. Defaults to document.documentElement. */
|
|
40
|
+
target?: HTMLElement | null;
|
|
41
|
+
/** If false, the select only writes `lang` and never touches `dir`. */
|
|
42
|
+
applyDir?: boolean;
|
|
43
|
+
/** Optional pretty labels per locale code. */
|
|
44
|
+
localeLabels?: Record<string, string>;
|
|
45
|
+
/** Replaces the default globe icon inside the button. */
|
|
46
|
+
children?: Snippet<[ChildArgs]>;
|
|
47
|
+
/** Called after the control applies a new locale. */
|
|
48
|
+
onChange?: (locale: string) => void;
|
|
49
|
+
/** Extra CSS class on the root. */
|
|
50
|
+
class?: string;
|
|
51
|
+
/** Spread props onto the root element. */
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
/** Convert a locale code to its BCP 47 hyphen form. */
|
|
55
|
+
export declare function bcp47LocaleTag(locale: string): string;
|
|
56
|
+
/** Detect whether a locale is right-to-left. See spec/index.md §5.6. */
|
|
57
|
+
export declare function isRtlLocale(locale: string): boolean;
|
|
58
|
+
/** Resolve a locale code to its English name via the built-in table. */
|
|
59
|
+
export declare function localeName(locale: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* The language's own name for itself — "de" → "Deutsch", "cy" →
|
|
62
|
+
* "Cymraeg" — from `Intl.DisplayNames` asked *in that language*.
|
|
63
|
+
*
|
|
64
|
+
* Endonyms are the right default for a language menu: the user who
|
|
65
|
+
* needs it most is the one lost in a UI that is not in their
|
|
66
|
+
* language, and they recognise "Cymraeg" where "Welsh" means
|
|
67
|
+
* nothing to them. Deterministic (no `navigator` dependency), so
|
|
68
|
+
* the server and the client render the same label. Returns "" when
|
|
69
|
+
* the runtime has no data — some runtimes echo the tag back instead
|
|
70
|
+
* of failing, and an echo is not a name.
|
|
71
|
+
*/
|
|
72
|
+
export declare function localeEndonym(locale: string): string;
|
|
73
|
+
/** Re-export the built-in label table and RTL sets for convenience. */
|
|
74
|
+
export { defaultLocaleLabels, RTL_LANGUAGE_TAGS, RTL_SCRIPT_SUBTAGS };
|
|
75
|
+
/** Match a navigator preference against a supported-locales list. */
|
|
76
|
+
export declare function matchNavigatorLanguage(navLangs: readonly string[], locales: readonly string[]): string | "";
|
|
77
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
78
|
+
export declare function nextLocalePickerId(): string;
|
|
79
|
+
declare const LocalePicker: import("svelte").Component<Props, {}, "value">;
|
|
80
|
+
type LocalePicker = ReturnType<typeof LocalePicker>;
|
|
81
|
+
export default LocalePicker;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, default as LocalePicker, bcp47LocaleTag, isRtlLocale, localeName, matchNavigatorLanguage, defaultLocaleLabels, RTL_LANGUAGE_TAGS, RTL_SCRIPT_SUBTAGS, } from "./LocalePicker.svelte";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Code → English name. Derived from `locales.tsv`. */
|
|
2
|
+
export declare const defaultLocaleLabels: Record<string, string>;
|
|
3
|
+
/** Base language subtags whose default writing direction is RTL. */
|
|
4
|
+
export declare const RTL_LANGUAGE_TAGS: ReadonlySet<string>;
|
|
5
|
+
/** Script subtags whose writing direction is RTL. */
|
|
6
|
+
export declare const RTL_SCRIPT_SUBTAGS: ReadonlySet<string>;
|