@lilydesignsystem/svelte-locale-picker 0.1.0 → 0.1.2

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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" module>
2
2
  import type { Snippet } from "svelte";
3
+ import { IconButton, Listbox } from "@lilydesignsystem/svelte-headless";
3
4
  import {
4
5
  defaultLocaleLabels,
5
6
  RTL_LANGUAGE_TAGS,
@@ -185,13 +186,9 @@
185
186
  let open = $state(false);
186
187
  let activeIndex = $state(-1);
187
188
  let buttonEl: HTMLButtonElement | undefined = $state();
188
- let listEl: HTMLUListElement | undefined = $state();
189
+ let listEl: HTMLElement | undefined = $state();
189
190
  let rootEl: HTMLDivElement | undefined = $state();
190
191
 
191
- // Typeahead buffer: APG listbox behaviour. Reset after a pause.
192
- let typeahead = "";
193
- let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
194
-
195
192
  function labelFor(locale: string): string {
196
193
  if (locale in localeLabels) return localeLabels[locale];
197
194
  // Endonym first: a language menu names each language in itself,
@@ -307,37 +304,26 @@
307
304
  el?.scrollIntoView?.({ block: "nearest" });
308
305
  }
309
306
 
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;
307
+ // Arrow/Home/End/PageUp/PageDown/typeahead/Escape/Tab keyboard handling
308
+ // inside the open list is owned by Listbox's "active-descendant" mode
309
+ // (see @lilydesignsystem/svelte-headless); this component only decides
310
+ // what open/close/choose/scroll mean. Keep the highlighted option in
311
+ // view for every reason activeIndex can change.
312
+ $effect(() => {
313
+ activeIndex;
314
314
  scrollActiveIntoView();
315
- }
315
+ });
316
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
- }
317
+ function handleTabOut(): void {
318
+ // Tab moves on — but focus goes to the button FIRST, without
319
+ // cancelling the key (Listbox's onTabOut never preventDefaults
320
+ // Tab). Hiding the focused list drops focus to <body>, and the
321
+ // browser then computes the default Tab move from the top of the
322
+ // document, so tabbing out of an open picker teleported the user to
323
+ // the page's first tab stop. From the button, the default Tab
324
+ // lands exactly where leaving the picker should.
325
+ buttonEl?.focus?.({ preventScroll: true });
326
+ closeList(false);
341
327
  }
342
328
 
343
329
  function onButtonKeydown(event: KeyboardEvent): void {
@@ -355,62 +341,6 @@
355
341
  }
356
342
  }
357
343
 
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
344
  function onRootFocusOut(event: FocusEvent): void {
415
345
  const next = event.relatedTarget as Node | null;
416
346
  if (next && rootEl?.contains(next)) return;
@@ -481,11 +411,10 @@
481
411
  >
482
412
  <input type="hidden" {name} {value} />
483
413
 
484
- <button
485
- bind:this={buttonEl}
486
- type="button"
487
- class="locale-picker-button"
488
- aria-label={label}
414
+ <IconButton
415
+ bind:ref={buttonEl}
416
+ baseClass="locale-picker-button"
417
+ label={label}
489
418
  aria-haspopup="listbox"
490
419
  aria-expanded={open}
491
420
  aria-controls={listId}
@@ -512,18 +441,23 @@
512
441
  <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
442
  </svg>
514
443
  {/if}
515
- </button>
444
+ </IconButton>
516
445
 
517
- <ul
518
- bind:this={listEl}
519
- class="locale-picker-list"
446
+ <Listbox
447
+ bind:ref={listEl}
448
+ as="ul"
449
+ baseClass="locale-picker-list"
520
450
  id={listId}
521
- role="listbox"
522
- aria-label={label}
523
- aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
524
- tabindex="-1"
451
+ label={label}
452
+ navigation="active-descendant"
453
+ clamp
454
+ typeahead
455
+ pageSize={10}
456
+ bind:activeIndex
525
457
  hidden={!open}
526
- onkeydown={onListKeydown}
458
+ onActivate={choose}
459
+ onEscape={() => closeList()}
460
+ onTabOut={handleTabOut}
527
461
  >
528
462
  {#each locales as locale, i (locale)}
529
463
  <!-- The option's keyboard interaction lives on the listbox
@@ -543,5 +477,5 @@
543
477
  {labelFor(locale)}
544
478
  </li>
545
479
  {/each}
546
- </ul>
480
+ </Listbox>
547
481
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lilydesignsystem/svelte-locale-picker",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "engines": {
5
5
  "node": "=26"
6
6
  },
@@ -23,6 +23,9 @@
23
23
  "scripts": {
24
24
  "prepublishOnly": "cd .. && npm run build"
25
25
  },
26
+ "dependencies": {
27
+ "@lilydesignsystem/svelte-headless": "^0.2.0"
28
+ },
26
29
  "peerDependencies": {
27
30
  "svelte": "^5.0.0"
28
31
  },