@sentropic/design-system-svelte 0.17.0 → 0.18.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.
@@ -173,22 +173,56 @@
173
173
  return d > rangeStart.getTime() && d < rangeEnd.getTime();
174
174
  }
175
175
 
176
- function previousMonth() {
177
- if (viewMonth === 0) {
178
- viewMonth = 11;
179
- viewYear -= 1;
180
- } else {
181
- viewMonth -= 1;
176
+ /**
177
+ * Retourne le premier jour activable (non-disabled) du mois `year`/`month`,
178
+ * en partant de `preferred` si celui-ci est dans le bon mois et non-disabled,
179
+ * sinon en balayant du 1er au dernier jour du mois.
180
+ * Renvoie `null` si tous les jours sont disabled (cas extrême).
181
+ */
182
+ function clampToMonth(preferred: Date, year: number, month: number): Date | null {
183
+ // Si preferred est dans le bon mois et non-disabled → on le garde.
184
+ if (
185
+ preferred.getFullYear() === year &&
186
+ preferred.getMonth() === month &&
187
+ !isOutOfBounds(preferred)
188
+ ) {
189
+ return preferred;
190
+ }
191
+ // Chercher le jour sélectionné dans ce mois en priorité.
192
+ const sel = !range ? single : rangeStart;
193
+ if (sel && sel.getFullYear() === year && sel.getMonth() === month && !isOutOfBounds(sel)) {
194
+ return sel;
195
+ }
196
+ // Balayer du 1er au dernier jour du mois.
197
+ const lastDay = new Date(year, month + 1, 0).getDate();
198
+ for (let d = 1; d <= lastDay; d++) {
199
+ const candidate = startOfDay(new Date(year, month, d));
200
+ if (!isOutOfBounds(candidate)) return candidate;
182
201
  }
202
+ // Aucun jour activable (mois entièrement hors-bornes) : retourner null pour
203
+ // signaler l'absence de cellule focusable. Les appelants doivent traiter ce cas.
204
+ return null;
205
+ }
206
+
207
+ function previousMonth() {
208
+ const targetMonth = viewMonth === 0 ? 11 : viewMonth - 1;
209
+ const targetYear = viewMonth === 0 ? viewYear - 1 : viewYear;
210
+ viewMonth = targetMonth;
211
+ viewYear = targetYear;
212
+ const clamped = clampToMonth(focusDate, targetYear, targetMonth);
213
+ if (clamped) focusDate = clamped;
214
+ // Si clamped === null, le mois est entièrement hors-bornes : focusDate garde
215
+ // l'ancienne valeur — aucune cellule ne sera tabindex=0 car toutes sont disabled.
183
216
  }
184
217
 
185
218
  function nextMonth() {
186
- if (viewMonth === 11) {
187
- viewMonth = 0;
188
- viewYear += 1;
189
- } else {
190
- viewMonth += 1;
191
- }
219
+ const targetMonth = viewMonth === 11 ? 0 : viewMonth + 1;
220
+ const targetYear = viewMonth === 11 ? viewYear + 1 : viewYear;
221
+ viewMonth = targetMonth;
222
+ viewYear = targetYear;
223
+ const clamped = clampToMonth(focusDate, targetYear, targetMonth);
224
+ if (clamped) focusDate = clamped;
225
+ // Si clamped === null, le mois est entièrement hors-bornes : idem.
192
226
  }
193
227
 
194
228
  function pickDate(date: Date) {
@@ -217,13 +251,149 @@
217
251
 
218
252
  const monthLabel = $derived(monthFormatter.format(new Date(viewYear, viewMonth, 1)));
219
253
 
254
+ // --- Roving tabindex : date active dans la grille -------------------------
255
+ // La "date active" est celle qui a tabindex=0 ; elle suit la sélection ou
256
+ // se positionne sur le 1er jour activable du mois affiché en l'absence de sélection.
257
+ // INVARIANT : focusDate est toujours dans le mois affiché ET non-disabled.
258
+ function initialFocusDate(): Date {
259
+ const sel = !range ? single : rangeStart;
260
+ if (sel && sel.getFullYear() === viewYear && sel.getMonth() === viewMonth && !isOutOfBounds(sel)) {
261
+ return sel;
262
+ }
263
+ // Trouver le premier jour activable du mois.
264
+ const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
265
+ for (let d = 1; d <= lastDay; d++) {
266
+ const candidate = startOfDay(new Date(viewYear, viewMonth, d));
267
+ if (!isOutOfBounds(candidate)) return candidate;
268
+ }
269
+ // Mois entièrement hors-bornes : retourner le 1er jour quand même pour
270
+ // initialiser focusDate, mais aucune cellule ne sera tabindex=0 (toutes disabled).
271
+ return startOfDay(new Date(viewYear, viewMonth, 1));
272
+ }
273
+
274
+ let focusDate = $state<Date>(initialFocusDate());
275
+
276
+ // Resynchronise focusDate quand la prop value change (sélection externe).
277
+ // Si la nouvelle valeur est dans le mois affiché et non-disabled, on la pointe ;
278
+ // sinon on re-clamp pour garantir l'invariant.
279
+ $effect(() => {
280
+ const sel = !range ? single : rangeStart;
281
+ if (sel) {
282
+ if (sel.getFullYear() === viewYear && sel.getMonth() === viewMonth && !isOutOfBounds(sel)) {
283
+ focusDate = sel;
284
+ } else {
285
+ const clamped = clampToMonth(focusDate, viewYear, viewMonth);
286
+ if (clamped) focusDate = clamped;
287
+ // Si null (mois entièrement hors-bornes), on ne touche pas focusDate :
288
+ // le tabindex=0 ne sera posé sur aucune cellule disabled.
289
+ }
290
+ }
291
+ });
292
+
293
+ // Resynchronise focusDate quand le mois affiché change via la prop `month`
294
+ // (l'$effect sur `month` dans le bloc précédent met viewYear/viewMonth à jour,
295
+ // mais focusDate peut pointer vers l'ancien mois).
296
+ $effect(() => {
297
+ // Dépendances explicites : viewYear + viewMonth.
298
+ const y = viewYear;
299
+ const m = viewMonth;
300
+ if (focusDate.getFullYear() !== y || focusDate.getMonth() !== m || isOutOfBounds(focusDate)) {
301
+ const clamped = clampToMonth(focusDate, y, m);
302
+ if (clamped) focusDate = clamped;
303
+ // Si null (mois entièrement hors-bornes), aucune cellule ne reçoit tabindex=0.
304
+ }
305
+ });
306
+
307
+ // Résoud l'élément DOM du jour actif et y place le focus.
308
+ let gridEl = $state<HTMLElement | null>(null);
309
+
310
+ function focusActiveCell() {
311
+ if (!gridEl) return;
312
+ const iso = toISO(focusDate);
313
+ const btn = gridEl.querySelector<HTMLElement>(`[data-date="${iso}"]`);
314
+ btn?.focus();
315
+ }
316
+
317
+ // Déplace focusDate de `deltaDays` jours ; change de mois si nécessaire.
318
+ function moveFocus(deltaDays: number) {
319
+ const next = new Date(focusDate);
320
+ next.setDate(next.getDate() + deltaDays);
321
+ // Si hors mois affiché, on bascule le mois.
322
+ if (next.getFullYear() !== viewYear || next.getMonth() !== viewMonth) {
323
+ viewYear = next.getFullYear();
324
+ viewMonth = next.getMonth();
325
+ }
326
+ focusDate = startOfDay(next);
327
+ // Focus après rendu.
328
+ setTimeout(focusActiveCell, 0);
329
+ }
330
+
220
331
  function onKeyDown(event: KeyboardEvent) {
221
- if (event.key === "PageUp") {
222
- event.preventDefault();
223
- previousMonth();
224
- } else if (event.key === "PageDown") {
225
- event.preventDefault();
226
- nextMonth();
332
+ switch (event.key) {
333
+ case "ArrowLeft":
334
+ event.preventDefault();
335
+ moveFocus(-1);
336
+ break;
337
+ case "ArrowRight":
338
+ event.preventDefault();
339
+ moveFocus(1);
340
+ break;
341
+ case "ArrowUp":
342
+ event.preventDefault();
343
+ moveFocus(-7);
344
+ break;
345
+ case "ArrowDown":
346
+ event.preventDefault();
347
+ moveFocus(7);
348
+ break;
349
+ case "Home": {
350
+ // Début de la semaine (selon weekStartsOn).
351
+ event.preventDefault();
352
+ const dayOfWeek = focusDate.getDay();
353
+ const offset = (dayOfWeek - weekStartsOn + 7) % 7;
354
+ moveFocus(-offset);
355
+ break;
356
+ }
357
+ case "End": {
358
+ // Fin de la semaine.
359
+ event.preventDefault();
360
+ const dayOfWeek = focusDate.getDay();
361
+ const offset = (6 - ((dayOfWeek - weekStartsOn + 7) % 7));
362
+ moveFocus(offset);
363
+ break;
364
+ }
365
+ case "PageUp": {
366
+ event.preventDefault();
367
+ // previousMonth() met à jour viewYear/viewMonth ET clamp focusDate via clampToMonth,
368
+ // en essayant de conserver le même numéro de jour dans le mois cible.
369
+ const puDay = focusDate.getDate();
370
+ const puTargetMonth = viewMonth === 0 ? 11 : viewMonth - 1;
371
+ const puTargetYear = viewMonth === 0 ? viewYear - 1 : viewYear;
372
+ // Construit le candidat "même jour" avant d'appeler previousMonth pour que
373
+ // clampToMonth puisse l'évaluer (il utilise focusDate en argument).
374
+ const puLastDay = new Date(puTargetYear, puTargetMonth + 1, 0).getDate();
375
+ focusDate = startOfDay(new Date(puTargetYear, puTargetMonth, Math.min(puDay, puLastDay)));
376
+ previousMonth();
377
+ setTimeout(focusActiveCell, 0);
378
+ break;
379
+ }
380
+ case "PageDown": {
381
+ event.preventDefault();
382
+ const pdDay = focusDate.getDate();
383
+ const pdTargetMonth = viewMonth === 11 ? 0 : viewMonth + 1;
384
+ const pdTargetYear = viewMonth === 11 ? viewYear + 1 : viewYear;
385
+ const pdLastDay = new Date(pdTargetYear, pdTargetMonth + 1, 0).getDate();
386
+ focusDate = startOfDay(new Date(pdTargetYear, pdTargetMonth, Math.min(pdDay, pdLastDay)));
387
+ nextMonth();
388
+ setTimeout(focusActiveCell, 0);
389
+ break;
390
+ }
391
+ case "Enter":
392
+ case " ": {
393
+ event.preventDefault();
394
+ if (!isOutOfBounds(focusDate)) pickDate(focusDate);
395
+ break;
396
+ }
227
397
  }
228
398
  }
229
399
  </script>
@@ -248,12 +418,15 @@
248
418
  <ChevronRight size={18} aria-hidden="true" />
249
419
  </button>
250
420
  </div>
421
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
422
+ <!-- Faux positif : le grid utilise le roving tabindex (cellules-enfants portent tabindex),
423
+ pas un tabindex sur le conteneur — conforme ARIA Grid Pattern. -->
251
424
  <div
252
425
  class="st-calendar__grid"
253
426
  role="grid"
254
- tabindex="-1"
255
427
  aria-label={monthLabel}
256
428
  onkeydown={onKeyDown}
429
+ bind:this={gridEl}
257
430
  >
258
431
  <div class="st-calendar__weekdays" role="row">
259
432
  {#each weekdayLabels as wd (wd)}
@@ -261,28 +434,38 @@
261
434
  {/each}
262
435
  </div>
263
436
  <div class="st-calendar__days">
264
- {#each grid as cell, i (i)}
265
- {@const oob = isOutOfBounds(cell.date)}
266
- {@const selected = isSelected(cell.date)}
267
- {@const inRange = isInRange(cell.date)}
268
- {@const isToday = isSameDay(cell.date, today)}
269
- <button
270
- type="button"
271
- class="st-calendar__day"
272
- class:st-calendar__day--outside={!cell.inMonth}
273
- class:st-calendar__day--selected={selected}
274
- class:st-calendar__day--inRange={inRange}
275
- class:st-calendar__day--today={isToday}
276
- role="gridcell"
277
- aria-label={cellFormatter.format(cell.date)}
278
- aria-selected={selected ? "true" : "false"}
279
- aria-current={isToday ? "date" : undefined}
280
- aria-disabled={oob ? "true" : undefined}
281
- disabled={oob}
282
- onclick={() => pickDate(cell.date)}
283
- >
284
- {cell.date.getDate()}
285
- </button>
437
+ {#each { length: 6 } as _, rowIdx (rowIdx)}
438
+ <div class="st-calendar__week" role="row">
439
+ {#each grid.slice(rowIdx * 7, rowIdx * 7 + 7) as cell, colIdx (rowIdx * 7 + colIdx)}
440
+ {@const oob = isOutOfBounds(cell.date)}
441
+ {@const selected = isSelected(cell.date)}
442
+ {@const inRange = isInRange(cell.date)}
443
+ {@const isToday = isSameDay(cell.date, today)}
444
+ {@const isActive = isSameDay(cell.date, focusDate)}
445
+ <button
446
+ type="button"
447
+ class="st-calendar__day"
448
+ class:st-calendar__day--outside={!cell.inMonth}
449
+ class:st-calendar__day--selected={selected}
450
+ class:st-calendar__day--inRange={inRange}
451
+ class:st-calendar__day--today={isToday}
452
+ role="gridcell"
453
+ aria-label={cellFormatter.format(cell.date)}
454
+ aria-selected={selected ? "true" : "false"}
455
+ aria-current={isToday ? "date" : undefined}
456
+ aria-disabled={oob ? "true" : undefined}
457
+ disabled={oob}
458
+ tabindex={isActive && !oob ? 0 : -1}
459
+ data-date={toISO(cell.date)}
460
+ onclick={() => {
461
+ focusDate = startOfDay(cell.date);
462
+ pickDate(cell.date);
463
+ }}
464
+ >
465
+ {cell.date.getDate()}
466
+ </button>
467
+ {/each}
468
+ </div>
286
469
  {/each}
287
470
  </div>
288
471
  </div>
@@ -340,10 +523,22 @@
340
523
  gap: var(--st-spacing-1, 0.25rem);
341
524
  }
342
525
 
343
- .st-calendar__weekdays,
526
+ .st-calendar__weekdays {
527
+ display: grid;
528
+ gap: 2px;
529
+ grid-template-columns: repeat(7, minmax(2rem, 1fr));
530
+ }
531
+
344
532
  .st-calendar__days {
345
533
  display: grid;
346
534
  gap: 2px;
535
+ }
536
+
537
+ /* role="row" doit être un vrai nœud exposé à l'arbre a11y.
538
+ display:contents supprime le nœud → on utilise display:grid à la place. */
539
+ .st-calendar__week {
540
+ display: grid;
541
+ gap: 2px;
347
542
  grid-template-columns: repeat(7, minmax(2rem, 1fr));
348
543
  }
349
544
 
@@ -1 +1 @@
1
- {"version":3,"file":"Calendar.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Calendar.svelte.ts"],"names":[],"mappings":"AAGE;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;AAI7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,aAAa,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAChF,wEAAwE;IACxE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC1C,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AA+OJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Calendar.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Calendar.svelte.ts"],"names":[],"mappings":"AAGE;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;AAI7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,aAAa,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAChF,wEAAwE;IACxE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC1C,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAmaJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -44,6 +44,23 @@
44
44
  class?: string;
45
45
  /** Notified whenever the resolved placement changes (after flip). */
46
46
  onPlacementChange?: (placement: PopperPlacement) => void;
47
+ /**
48
+ * (a11y, opt-in) When true, traps keyboard focus inside the panel while it
49
+ * is open (Tab cycles through focusable children; Shift+Tab cycles backward).
50
+ * Intended for modal overlays built on top of Popper. Non-modal usage (menus,
51
+ * tooltips) should leave this false (default) to keep the natural tab order.
52
+ */
53
+ trapFocus?: boolean;
54
+ /**
55
+ * (a11y) When true (default when open), pressing Escape calls `onClose` so
56
+ * the consumer can set `open = false`. Set to false to suppress this behavior.
57
+ */
58
+ closeOnEscape?: boolean;
59
+ /**
60
+ * Called when the panel requests closing (Escape key, or future outside-click).
61
+ * The consumer is responsible for updating `open` in response.
62
+ */
63
+ onClose?: () => void;
47
64
  children?: Snippet;
48
65
  };
49
66
 
@@ -158,6 +175,8 @@
158
175
  </script>
159
176
 
160
177
  <script lang="ts">
178
+ import { untrack } from "svelte";
179
+
161
180
  let {
162
181
  anchor,
163
182
  open = false,
@@ -170,6 +189,9 @@
170
189
  portal = true,
171
190
  class: className,
172
191
  onPlacementChange,
192
+ trapFocus = false,
193
+ closeOnEscape = true,
194
+ onClose,
173
195
  children
174
196
  }: PopperProps = $props();
175
197
 
@@ -243,17 +265,152 @@
243
265
  };
244
266
  });
245
267
 
268
+ // ─── a11y: focus management ────────────────────────────────────────────────
269
+ // Capture the element that had focus before the panel opened so we can
270
+ // restore it when the panel closes (only when trapFocus is active).
271
+ // SSR-safe (guarded by typeof window).
272
+ // Use a plain variable (not $state) so reads inside the effect are not
273
+ // tracked and don't cause the effect to re-run on every preFocusEl write.
274
+ let preFocusEl: HTMLElement | null = null;
275
+
276
+ $effect(() => {
277
+ if (typeof window === "undefined") return;
278
+ if (open && anchor) {
279
+ untrack(() => {
280
+ // Snapshot the active element at open time only when trapFocus is active,
281
+ // so restoreFocus doesn't steal focus on non-modal usage.
282
+ if (trapFocus) {
283
+ preFocusEl = document.activeElement as HTMLElement | null;
284
+ }
285
+ });
286
+ } else {
287
+ // Panel just closed: restore focus only if trapFocus was active and the
288
+ // original element is still connected to the DOM.
289
+ untrack(() => {
290
+ if (
291
+ trapFocus &&
292
+ preFocusEl &&
293
+ typeof preFocusEl.focus === "function" &&
294
+ document.contains(preFocusEl)
295
+ ) {
296
+ preFocusEl.focus();
297
+ }
298
+ preFocusEl = null;
299
+ });
300
+ }
301
+ });
302
+
303
+ /** Returns all keyboard-focusable children of `container`. */
304
+ function getFocusable(container: HTMLElement): HTMLElement[] {
305
+ if (typeof window === "undefined") return [];
306
+ return Array.from(
307
+ container.querySelectorAll<HTMLElement>(
308
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),' +
309
+ 'textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'
310
+ )
311
+ ).filter((el) => !el.closest("[disabled]"));
312
+ }
313
+
314
+ // ─── a11y: move focus into the panel on open (trapFocus only) ─────────────
315
+ $effect(() => {
316
+ if (typeof window === "undefined") return;
317
+ if (!open || !trapFocus || !panel) return;
318
+ // Wait for the panel to be mounted before moving focus.
319
+ untrack(() => {
320
+ const focusable = getFocusable(panel!);
321
+ if (focusable.length > 0) {
322
+ focusable[0].focus();
323
+ } else {
324
+ // If no focusable children, focus the panel itself so Escape works.
325
+ panel!.focus();
326
+ }
327
+ });
328
+ });
329
+
330
+ // ─── a11y: recapture focus that escapes the trap (focusin on document) ─────
331
+ $effect(() => {
332
+ if (typeof window === "undefined") return;
333
+ if (!open || !trapFocus) return;
334
+
335
+ function handleFocusIn(e: FocusEvent) {
336
+ if (!panel) return;
337
+ const target = e.target as Node | null;
338
+ // If focus landed outside the panel, bring it back to the first focusable.
339
+ if (target && !panel.contains(target)) {
340
+ const focusable = getFocusable(panel);
341
+ if (focusable.length > 0) {
342
+ focusable[0].focus();
343
+ } else {
344
+ panel.focus();
345
+ }
346
+ }
347
+ }
348
+
349
+ document.addEventListener("focusin", handleFocusIn);
350
+ return () => {
351
+ document.removeEventListener("focusin", handleFocusIn);
352
+ };
353
+ });
354
+
355
+ // ─── a11y: Escape on document (closeOnEscape) ─────────────────────────────
356
+ // Listen on document so the Escape shortcut works even when focus is outside
357
+ // the panel (e.g. before the first Tab, or in an edge case where focus escaped).
358
+ $effect(() => {
359
+ if (typeof window === "undefined") return;
360
+ if (!open || !closeOnEscape) return;
361
+
362
+ function handleDocKeydown(e: KeyboardEvent) {
363
+ if (e.key === "Escape") {
364
+ e.preventDefault();
365
+ onClose?.();
366
+ }
367
+ }
368
+
369
+ document.addEventListener("keydown", handleDocKeydown);
370
+ return () => {
371
+ document.removeEventListener("keydown", handleDocKeydown);
372
+ };
373
+ });
374
+
375
+ /** Keyboard handler attached to the panel for Tab-trap cycling. */
376
+ function handlePanelKeydown(e: KeyboardEvent) {
377
+ if (typeof window === "undefined") return;
378
+
379
+ // Tab trap: cycle focus within the panel.
380
+ if (e.key === "Tab" && trapFocus && panel) {
381
+ const focusable = getFocusable(panel);
382
+ if (focusable.length === 0) { e.preventDefault(); return; }
383
+ const first = focusable[0];
384
+ const last = focusable[focusable.length - 1];
385
+ if (e.shiftKey) {
386
+ if (document.activeElement === first) {
387
+ e.preventDefault();
388
+ last.focus();
389
+ }
390
+ } else {
391
+ if (document.activeElement === last) {
392
+ e.preventDefault();
393
+ first.focus();
394
+ }
395
+ }
396
+ }
397
+ }
398
+
246
399
  const panelStyle = () =>
247
400
  `position: ${strategy}; top: ${top}px; left: ${left}px;`;
248
401
  const panelSide = () => splitPlacement(resolvedPlacement).side;
249
402
  </script>
250
403
 
251
404
  {#snippet floating()}
405
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
406
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
252
407
  <div
253
408
  bind:this={panel}
254
409
  class={className ? `st-popper ${className}` : "st-popper"}
255
410
  data-popper-placement={resolvedPlacement}
256
411
  style={panelStyle()}
412
+ tabindex={trapFocus ? -1 : undefined}
413
+ onkeydown={trapFocus ? handlePanelKeydown : undefined}
257
414
  >
258
415
  {@render children?.()}
259
416
  {#if arrow}
@@ -26,6 +26,23 @@ export type PopperProps = {
26
26
  class?: string;
27
27
  /** Notified whenever the resolved placement changes (after flip). */
28
28
  onPlacementChange?: (placement: PopperPlacement) => void;
29
+ /**
30
+ * (a11y, opt-in) When true, traps keyboard focus inside the panel while it
31
+ * is open (Tab cycles through focusable children; Shift+Tab cycles backward).
32
+ * Intended for modal overlays built on top of Popper. Non-modal usage (menus,
33
+ * tooltips) should leave this false (default) to keep the natural tab order.
34
+ */
35
+ trapFocus?: boolean;
36
+ /**
37
+ * (a11y) When true (default when open), pressing Escape calls `onClose` so
38
+ * the consumer can set `open = false`. Set to false to suppress this behavior.
39
+ */
40
+ closeOnEscape?: boolean;
41
+ /**
42
+ * Called when the panel requests closing (Escape key, or future outside-click).
43
+ * The consumer is responsible for updating `open` in response.
44
+ */
45
+ onClose?: () => void;
29
46
  children?: Snippet;
30
47
  };
31
48
  /** Split a placement into its side and (optional) alignment. */
@@ -1 +1 @@
1
- {"version":3,"file":"Popper.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Popper.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,QAAQ,GACR,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,GACT,cAAc,GACd,YAAY,GACZ,YAAY,GACZ,UAAU,GACV,aAAa,GACb,WAAW,CAAC;AAEhB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG;IACxB,yDAAyD;IACzD,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,SAAS,EAAE,eAAe,GAAG;IAC1D,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;CACpB,CAGA;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,eAAe,CAEnF;AASD,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,IAAI,EAChB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IACP,SAAS,EAAE,eAAe,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,SAAS,EAAE,eAAe,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwD3D;AAsHH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"Popper.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Popper.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,QAAQ,GACR,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,GACT,cAAc,GACd,YAAY,GACZ,YAAY,GACZ,UAAU,GACV,aAAa,GACb,WAAW,CAAC;AAEhB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG;IACxB,yDAAyD;IACzD,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACzD;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,SAAS,EAAE,eAAe,GAAG;IAC1D,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;CACpB,CAGA;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,eAAe,CAEnF;AASD,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,IAAI,EAChB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IACP,SAAS,EAAE,eAAe,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,SAAS,EAAE,eAAe,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwD3D;AAmQH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
@@ -49,8 +49,21 @@
49
49
  const stars = $derived(Array.from({ length: max }, (_, i) => i + 1));
50
50
 
51
51
  // L'étoile « focusable » (tabindex 0) suit la valeur ; à 0 c'est la première.
52
+ // En mode allowHalf, la valeur peut être un demi-entier : on focus l'étoile plafond.
52
53
  const focusedStar = $derived(value > 0 ? Math.ceil(value) : 1);
53
54
 
55
+ // Refs des boutons radio pour déplacer le focus programmatiquement.
56
+ let radioRefs = $state<Record<number, HTMLElement | null>>({});
57
+
58
+ // Texte accessible décrivant la valeur courante (utilisé pour aria-valuetext et aria-label readonly).
59
+ const valueText = $derived(
60
+ value === 0
61
+ ? `0 / ${max}`
62
+ : allowHalf && value % 1 !== 0
63
+ ? `${value} / ${max}`
64
+ : `${value} / ${max}`
65
+ );
66
+
54
67
  function fill(star: number): "full" | "half" | "empty" {
55
68
  if (value >= star) return "full";
56
69
  if (allowHalf && value >= star - 0.5) return "half";
@@ -85,64 +98,140 @@
85
98
  if (readonly) return;
86
99
  const step = allowHalf ? 0.5 : 1;
87
100
  let handled = true;
101
+ let next: number | null = null;
88
102
  switch (event.key) {
89
103
  case "ArrowRight":
90
104
  case "ArrowUp":
91
- commit(Math.min(max, value + step));
105
+ next = Math.min(max, value + step);
92
106
  break;
93
107
  case "ArrowLeft":
94
108
  case "ArrowDown":
95
- commit(Math.max(0, value - step));
109
+ // En mode entier, ne pas descendre sous 1 (pas de radio "0").
110
+ next = allowHalf ? Math.max(0, value - step) : Math.max(1, value - step);
96
111
  break;
97
112
  case "Home":
98
- commit(0);
113
+ // Home → première étoile (1), pas 0 (aucun radio "0" n'existe).
114
+ next = allowHalf ? 0 : 1;
99
115
  break;
100
116
  case "End":
101
- commit(max);
117
+ next = max;
102
118
  break;
103
119
  default:
104
120
  handled = false;
105
121
  }
106
- if (handled) event.preventDefault();
122
+ if (handled) {
123
+ event.preventDefault();
124
+ if (next !== null) {
125
+ commit(next);
126
+ // En mode entier, déplacer le focus DOM vers le radio cible.
127
+ if (!allowHalf) {
128
+ const targetStar = next > 0 ? Math.ceil(next) : 1;
129
+ const targetEl = radioRefs[targetStar];
130
+ if (targetEl) targetEl.focus();
131
+ }
132
+ }
133
+ }
107
134
  }
135
+
136
+ // En mode allowHalf, on expose un slider ARIA (valeurs fractionnaires non représentables
137
+ // fidèlement par un radiogroup). En mode entier, on garde radiogroup/radio.
138
+ // Readonly : rendu non interactif avec span + aria-label global pour éviter les boutons disabled
139
+ // qui disparaissent de l'arbre d'accessibilité interactif.
108
140
  </script>
109
141
 
110
- <div
111
- {...rest}
112
- class={classes}
113
- role="radiogroup"
114
- aria-label={label}
115
- aria-readonly={readonly ? "true" : undefined}
116
- >
117
- {#each stars as star (star)}
118
- {@const state = fill(star)}
119
- <button
120
- type="button"
121
- class="st-rating__star"
122
- class:st-rating__star--full={state === "full"}
123
- class:st-rating__star--half={state === "half"}
124
- role="radio"
125
- name={name}
126
- aria-checked={Math.ceil(value) === star ? "true" : "false"}
127
- aria-label={`${star} / ${max}`}
128
- tabindex={!readonly && star === focusedStar ? 0 : -1}
129
- disabled={readonly}
130
- onclick={(event) => onStarClick(event, star)}
131
- onkeydown={onKeyDown}
132
- >
133
- {#if state === "half"}
134
- <StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
135
- {:else}
142
+ {#if readonly}
143
+ <!-- Readonly : pas d'éléments interactifs disabled — on expose la note via aria-label sur le groupe. -->
144
+ <div
145
+ {...rest}
146
+ class={classes}
147
+ role="img"
148
+ aria-label={label ? `${label} : ${valueText}` : valueText}
149
+ >
150
+ {#each stars as star (star)}
151
+ {@const state = fill(star)}
152
+ <span class="st-rating__star" class:st-rating__star--full={state === "full"} class:st-rating__star--half={state === "half"} aria-hidden="true">
153
+ {#if state === "half"}
154
+ <StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
155
+ {:else}
156
+ <Star
157
+ size={iconSize}
158
+ strokeWidth={1.75}
159
+ fill={state === "full" ? "currentColor" : "none"}
160
+ aria-hidden="true"
161
+ />
162
+ {/if}
163
+ </span>
164
+ {/each}
165
+ </div>
166
+ {:else if allowHalf}
167
+ <!-- allowHalf : slider ARIA — valeurs fractionnaires (0.5 step), plus fidèle que radiogroup. -->
168
+ <div
169
+ {...rest}
170
+ class={classes}
171
+ role="slider"
172
+ aria-label={label}
173
+ aria-valuemin={0}
174
+ aria-valuemax={max}
175
+ aria-valuenow={value}
176
+ aria-valuetext={valueText}
177
+ tabindex={0}
178
+ onkeydown={onKeyDown}
179
+ >
180
+ {#each stars as star (star)}
181
+ {@const state = fill(star)}
182
+ <span
183
+ class="st-rating__star"
184
+ class:st-rating__star--full={state === "full"}
185
+ class:st-rating__star--half={state === "half"}
186
+ aria-hidden="true"
187
+ onclick={(event) => onStarClick(event, star)}
188
+ >
189
+ {#if state === "half"}
190
+ <StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
191
+ {:else}
192
+ <Star
193
+ size={iconSize}
194
+ strokeWidth={1.75}
195
+ fill={state === "full" ? "currentColor" : "none"}
196
+ aria-hidden="true"
197
+ />
198
+ {/if}
199
+ </span>
200
+ {/each}
201
+ </div>
202
+ {:else}
203
+ <!-- Mode entier : radiogroup / radio. aria-checked=true uniquement sur l'étoile == value. -->
204
+ <div
205
+ {...rest}
206
+ class={classes}
207
+ role="radiogroup"
208
+ aria-label={label}
209
+ >
210
+ {#each stars as star (star)}
211
+ {@const state = fill(star)}
212
+ <button
213
+ type="button"
214
+ class="st-rating__star"
215
+ class:st-rating__star--full={state === "full"}
216
+ role="radio"
217
+ name={name}
218
+ aria-checked={value === star ? "true" : "false"}
219
+ aria-label={`${star} / ${max}`}
220
+ tabindex={star === focusedStar ? 0 : -1}
221
+ bind:this={radioRefs[star]}
222
+ onclick={(event) => onStarClick(event, star)}
223
+ onkeydown={onKeyDown}
224
+ >
136
225
  <Star
137
226
  size={iconSize}
138
227
  strokeWidth={1.75}
139
228
  fill={state === "full" ? "currentColor" : "none"}
140
229
  aria-hidden="true"
141
230
  />
142
- {/if}
143
- </button>
144
- {/each}
145
- </div>
231
+ </button>
232
+ {/each}
233
+ </div>
234
+ {/if}
146
235
 
147
236
  <style>
148
237
  .st-rating {
@@ -186,4 +275,10 @@
186
275
  .st-rating--readonly .st-rating__star {
187
276
  cursor: default;
188
277
  }
278
+
279
+ /* Mode allowHalf : le slider (conteneur) doit afficher un focus-visible. */
280
+ [role="slider"].st-rating:focus-visible {
281
+ outline: 2px solid var(--st-component-control-focusRing, var(--st-semantic-border-interactive));
282
+ outline-offset: 2px;
283
+ }
189
284
  </style>
@@ -1 +1 @@
1
- {"version":3,"file":"Rating.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Rating.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAI9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,WAAW,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAC9E,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA2GJ,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"Rating.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Rating.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAI9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,WAAW,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAC9E,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAsKJ,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
@@ -60,7 +60,7 @@
60
60
 
61
61
  // --- Row registry: ordered by DOM position so arrow nav matches the visual
62
62
  // order regardless of registration timing. -------------------------------
63
- type Entry = { el: HTMLElement; value: string | undefined };
63
+ type Entry = { el: HTMLElement; value: string | undefined; disabled?: boolean };
64
64
  let entries = $state<Entry[]>([]);
65
65
 
66
66
  // The element that currently holds the roving tab stop (tabindex 0). Null until
@@ -79,9 +79,14 @@
79
79
  // register/unregister are called from each row's $effect. They read AND write
80
80
  // `entries`, so the read must be untracked — otherwise the calling effect would
81
81
  // subscribe to `entries`, and writing it would re-run the effect forever.
82
- function register(el: HTMLElement, rowValue: string | undefined): () => void {
82
+ // Disabled rows are registered with disabled:true so navigate() can skip them
83
+ // explicitly, making the skip correct even when disabled state changes mid-session.
84
+ function register(el: HTMLElement, rowValue: string | undefined, rowDisabled = false): () => void {
83
85
  untrack(() => {
84
- entries = sortByDom([...entries.filter((e) => e.el !== el), { el, value: rowValue }]);
86
+ entries = sortByDom([
87
+ ...entries.filter((e) => e.el !== el),
88
+ { el, value: rowValue, disabled: rowDisabled }
89
+ ]);
85
90
  });
86
91
  return () => {
87
92
  untrack(() => {
@@ -91,8 +96,30 @@
91
96
  };
92
97
  }
93
98
 
94
- // Default roving stop = first registered (DOM-ordered) row when none focused.
95
- const effectiveTabStop = $derived(tabStopEl ?? entries[0]?.el ?? null);
99
+ // Default roving stop = first non-disabled DOM-ordered row when none focused,
100
+ // or when the current tabStopEl has become disabled.
101
+ const effectiveTabStop = $derived.by((): HTMLElement | null => {
102
+ if (tabStopEl) {
103
+ const entry = entries.find((e) => e.el === tabStopEl);
104
+ if (entry && !entry.disabled) return tabStopEl;
105
+ }
106
+ return entries.find((e) => !e.disabled)?.el ?? null;
107
+ });
108
+
109
+ // Si la row qui détient le focus DOM devient disabled (in-place, sans unmount),
110
+ // transférer le focus vers la nouvelle cible de roving tabindex.
111
+ // Note : le cas du cycle unregister/register est géré dans SelectableRow via
112
+ // l'$effect sur `disabled` qui appelle navigate() AVANT le cleanup.
113
+ $effect(() => {
114
+ const newStop = effectiveTabStop;
115
+ if (!newStop) return;
116
+ if (tabStopEl !== null) {
117
+ const disabledEntry = entries.find((e) => e.el === tabStopEl && e.disabled);
118
+ if (disabledEntry && tabStopEl.contains(document.activeElement ?? null)) {
119
+ newStop.focus();
120
+ }
121
+ }
122
+ });
96
123
 
97
124
  function valueOf(el: HTMLElement): string | undefined {
98
125
  return entries.find((e) => e.el === el)?.value;
@@ -137,13 +164,34 @@
137
164
  if (entries.length === 0) return;
138
165
  const idx = entries.findIndex((e) => e.el === el);
139
166
  if (idx === -1) return;
140
- let targetIdx = idx;
141
- if (key === "ArrowDown" || key === "ArrowRight") targetIdx = idx + 1;
142
- else if (key === "ArrowUp" || key === "ArrowLeft") targetIdx = idx - 1;
143
- else if (key === "Home") targetIdx = 0;
144
- else if (key === "End") targetIdx = entries.length - 1;
145
- // Clamp (no wrap) so Home/End and arrows stay within bounds.
146
- targetIdx = Math.max(0, Math.min(entries.length - 1, targetIdx));
167
+
168
+ let targetIdx: number | null = null;
169
+
170
+ if (key === "ArrowDown" || key === "ArrowRight") {
171
+ // Walk forward from current position, find the next non-disabled entry.
172
+ for (let i = idx + 1; i < entries.length; i++) {
173
+ if (!entries[i].disabled) { targetIdx = i; break; }
174
+ }
175
+ } else if (key === "ArrowUp" || key === "ArrowLeft") {
176
+ // Walk backward from current position, find the previous non-disabled entry.
177
+ for (let i = idx - 1; i >= 0; i--) {
178
+ if (!entries[i].disabled) { targetIdx = i; break; }
179
+ }
180
+ } else if (key === "Home") {
181
+ // First non-disabled entry.
182
+ for (let i = 0; i < entries.length; i++) {
183
+ if (!entries[i].disabled) { targetIdx = i; break; }
184
+ }
185
+ } else if (key === "End") {
186
+ // Last non-disabled entry.
187
+ for (let i = entries.length - 1; i >= 0; i--) {
188
+ if (!entries[i].disabled) { targetIdx = i; break; }
189
+ }
190
+ }
191
+
192
+ // If no target found (all remaining are disabled, or already at boundary), stay put.
193
+ if (targetIdx === null) return;
194
+
147
195
  const target = entries[targetIdx]?.el;
148
196
  if (target) {
149
197
  tabStopEl = target;
@@ -1 +1 @@
1
- {"version":3,"file":"SelectableList.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableList.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,mBAAmB,GAAG;IAChC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA0JJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"SelectableList.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableList.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,mBAAmB,GAAG;IAChC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA0MJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -16,8 +16,8 @@
16
16
  readonly managed: true;
17
17
  /** listbox role for the wrapper → rows are "option". */
18
18
  readonly itemRole: "option";
19
- /** Register a row element; returns an unregister callback. */
20
- register: (el: HTMLElement, value: string | undefined) => () => void;
19
+ /** Register a row element; returns an unregister callback. disabled is forwarded so the list can skip it during keyboard navigation. */
20
+ register: (el: HTMLElement, value: string | undefined, disabled?: boolean) => () => void;
21
21
  /** Is the row with this element currently selected? */
22
22
  isSelected: (el: HTMLElement) => boolean;
23
23
  /** Should the row with this element be the roving-tabindex stop (tabindex 0)? */
@@ -44,8 +44,9 @@
44
44
  /** Stable value, surfaced as `data-value` and used by the list for `value`. */
45
45
  value?: string;
46
46
  /**
47
- * ARIA role for the standalone row. Defaults to "option" so a lone row still
48
- * reads as a selectable item. Inside a list the role is forced to "option".
47
+ * ARIA role for the standalone row. Defaults to "button" for standalone use
48
+ * "option" is only valid inside a listbox and would be invalid without one.
49
+ * Inside a SelectableList the role is always forced to "option".
49
50
  */
50
51
  role?: string;
51
52
  /**
@@ -71,7 +72,7 @@
71
72
  onselect,
72
73
  disabled = false,
73
74
  value,
74
- role = "option",
75
+ role = "button",
75
76
  accentBar = false,
76
77
  leading,
77
78
  trailing,
@@ -86,10 +87,23 @@
86
87
  let el: HTMLElement | null = $state(null);
87
88
 
88
89
  // Register with the parent list (if any) so it can order rows for arrow nav
89
- // and compute the roving tab stop. The effect re-registers if value changes.
90
+ // and compute the roving tab stop. Disabled rows are registered too so the
91
+ // list can skip them during navigation; the list owns the skip logic.
90
92
  $effect(() => {
91
- if (!list || !el || disabled) return;
92
- return list.register(el, value);
93
+ if (!list || !el) return;
94
+ return list.register(el, value, disabled);
95
+ });
96
+
97
+ // A11y edge-case : quand cette row passe à disabled=true ET qu'elle détient le
98
+ // focus DOM, transférer le focus vers la prochaine row enabled via navigate().
99
+ // On le fait ici (dans SelectableRow) pour avoir accès au focus DOM AVANT que
100
+ // le cycle unregister/register dans SelectableList ne perturbe l'état.
101
+ $effect(() => {
102
+ if (!disabled || !list || !el) return;
103
+ if (!el.contains(document.activeElement ?? null)) return;
104
+ // Déléguer via navigate ArrowDown (cherche prochaine row enabled vers l'avant,
105
+ // puis vers l'arrière si aucune). navigate appelle target.focus() directement.
106
+ list.navigate(el, "ArrowDown");
93
107
  });
94
108
 
95
109
  // Effective selected state: list-managed rows read the list; standalone rows
@@ -165,6 +179,7 @@
165
179
  class={classes}
166
180
  role={effectiveRole}
167
181
  aria-selected={effectiveRole === "option" ? isSelected : undefined}
182
+ aria-pressed={effectiveRole === "button" ? isSelected : undefined}
168
183
  aria-disabled={disabled ? "true" : undefined}
169
184
  data-value={value}
170
185
  {tabindex}
@@ -13,8 +13,8 @@ export type SelectableListContext = {
13
13
  readonly managed: true;
14
14
  /** listbox role for the wrapper → rows are "option". */
15
15
  readonly itemRole: "option";
16
- /** Register a row element; returns an unregister callback. */
17
- register: (el: HTMLElement, value: string | undefined) => () => void;
16
+ /** Register a row element; returns an unregister callback. disabled is forwarded so the list can skip it during keyboard navigation. */
17
+ register: (el: HTMLElement, value: string | undefined, disabled?: boolean) => () => void;
18
18
  /** Is the row with this element currently selected? */
19
19
  isSelected: (el: HTMLElement) => boolean;
20
20
  /** Should the row with this element be the roving-tabindex stop (tabindex 0)? */
@@ -40,8 +40,9 @@ export type SelectableRowProps = {
40
40
  /** Stable value, surfaced as `data-value` and used by the list for `value`. */
41
41
  value?: string;
42
42
  /**
43
- * ARIA role for the standalone row. Defaults to "option" so a lone row still
44
- * reads as a selectable item. Inside a list the role is forced to "option".
43
+ * ARIA role for the standalone row. Defaults to "button" for standalone use
44
+ * "option" is only valid inside a listbox and would be invalid without one.
45
+ * Inside a SelectableList the role is always forced to "option".
45
46
  */
46
47
  role?: string;
47
48
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"SelectableRow.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableRow.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,eAA+B,CAAC;AAEhE,MAAM,MAAM,qBAAqB,GAAG;IAClC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IACvB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,IAAI,CAAC;IACrE,uDAAuD;IACvD,UAAU,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACzC,iFAAiF;IACjF,SAAS,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACxC,6EAA6E;IAC7E,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,wDAAwD;IACxD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,gDAAgD;IAChD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAoHJ,QAAA,MAAM,aAAa,gEAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"SelectableRow.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableRow.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,eAA+B,CAAC;AAEhE,MAAM,MAAM,qBAAqB,GAAG;IAClC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IACvB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,wIAAwI;IACxI,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;IACzF,uDAAuD;IACvD,UAAU,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACzC,iFAAiF;IACjF,SAAS,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACxC,6EAA6E;IAC7E,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,wDAAwD;IACxD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,gDAAgD;IAChD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiIJ,QAAA,MAAM,aAAa,gEAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -43,6 +43,9 @@
43
43
 
44
44
  const items = $derived(Array.from({ length: Math.max(0, count) }, (_, i) => i));
45
45
 
46
+ // Refs des boutons pour déplacer le focus programmatiquement lors de la navigation clavier.
47
+ let buttonRefs = $state<Record<number, HTMLElement | null>>({});
48
+
46
49
  function select(index: number) {
47
50
  if (index < 0 || index >= count || index === current) return;
48
51
  onChange?.(index);
@@ -69,21 +72,32 @@
69
72
  return;
70
73
  }
71
74
  event.preventDefault();
75
+ // Déplacer le focus DOM vers le bouton cible (roving tabindex correct).
76
+ const targetEl = buttonRefs[target];
77
+ if (targetEl) targetEl.focus();
72
78
  select(target);
73
79
  }
74
80
  </script>
75
81
 
76
- <div {...rest} class={classes} role="tablist" aria-label={label}>
82
+ <!--
83
+ Choix de pattern : role="group" + boutons avec aria-current.
84
+ Justification : un indicateur de carrousel/pagination n'est PAS un tablist — il n'y a pas de
85
+ tabpanel associé. Utiliser role="tab" sans aria-controls/tabpanel trompe les lecteurs d'écran
86
+ qui annoncent « onglet X sur N » sans panneau contrôlé.
87
+ Pattern retenu (ARIA Authoring Practices Guide — Carousel) : role="group" nommé + boutons natifs
88
+ avec aria-current="true" sur le point courant + roving tabindex.
89
+ Le SR annonce « Groupe [label] — [label] 1, bouton ; [label] 2, courant, bouton ; … »
90
+ -->
91
+ <div {...rest} class={classes} role="group" aria-label={label}>
77
92
  {#each items as index (index)}
78
93
  <button
79
94
  type="button"
80
95
  class="st-slideIndicator__dot"
81
96
  class:st-slideIndicator__dot--current={index === current}
82
- role="tab"
83
- aria-selected={index === current ? "true" : "false"}
84
97
  aria-current={index === current ? "true" : undefined}
85
98
  aria-label={`${label} ${index + 1}`}
86
99
  tabindex={index === current ? 0 : -1}
100
+ bind:this={buttonRefs[index]}
87
101
  onclick={() => select(index)}
88
102
  onkeydown={(event) => onKeyDown(event, index)}
89
103
  ></button>
@@ -1 +1 @@
1
- {"version":3,"file":"SlideIndicator.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SlideIndicator.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,MAAM,CAAC;AAGtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,mBAAmB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IACtF,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAChC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAuEJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"SlideIndicator.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SlideIndicator.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,MAAM,CAAC;AAGtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,mBAAmB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IACtF,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAChC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA8EJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -87,24 +87,167 @@
87
87
 
88
88
  let open = $state(false);
89
89
  let hostEl = $state<HTMLDivElement | null>(null);
90
+ let inputEl = $state<HTMLInputElement | null>(null);
91
+ let listEl = $state<HTMLUListElement | null>(null);
92
+
93
+ /** Index de l'option mise en évidence dans la listbox (-1 = aucune). */
94
+ let activeIndex = $state(-1);
90
95
 
91
96
  const displayValue = $derived(value ? display(value) : "");
92
97
 
98
+ /** Id de l'option active, consommé par aria-activedescendant. */
99
+ const activeDescendant = $derived(
100
+ open && activeIndex >= 0 ? `${listId}-opt-${activeIndex}` : undefined
101
+ );
102
+
103
+ function openList() {
104
+ if (disabled) return;
105
+ open = true;
106
+ // À l'ouverture : se positionner sur la valeur sélectionnée ou la première option.
107
+ const idx = value ? slots.indexOf(value) : -1;
108
+ activeIndex = idx >= 0 ? idx : 0;
109
+ // Le focus reste sur l'input (pattern aria-activedescendant).
110
+ }
111
+
112
+ function closeList(returnFocus = true) {
113
+ open = false;
114
+ activeIndex = -1;
115
+ if (returnFocus && inputEl) {
116
+ inputEl.focus();
117
+ }
118
+ }
119
+
93
120
  function toggleOpen() {
94
121
  if (disabled) return;
95
- open = !open;
122
+ if (open) {
123
+ closeList(true);
124
+ } else {
125
+ openList();
126
+ }
96
127
  }
97
128
 
98
129
  function pick(slot: string) {
99
130
  value = slot;
100
131
  onChange?.(slot);
101
- open = false;
132
+ closeList(true);
102
133
  }
103
134
 
104
- function onPanelKeyDown(event: KeyboardEvent) {
105
- if (event.key === "Escape" && open) {
106
- event.preventDefault();
107
- open = false;
135
+ /** Fait défiler la listbox pour que l'option active soit visible. */
136
+ function scrollActiveIntoView() {
137
+ if (!listEl || activeIndex < 0) return;
138
+ const optEl = listEl.querySelector<HTMLElement>(`#${listId}-opt-${activeIndex}`);
139
+ if (optEl && typeof optEl.scrollIntoView === "function") {
140
+ optEl.scrollIntoView({ block: "nearest" });
141
+ }
142
+ }
143
+
144
+ function onInputKeyDown(event: KeyboardEvent) {
145
+ if (disabled) return;
146
+ switch (event.key) {
147
+ case "ArrowDown": {
148
+ event.preventDefault();
149
+ if (!open) {
150
+ openList();
151
+ } else {
152
+ // ArrowDown avec liste déjà ouverte → descendre d'une option.
153
+ activeIndex = Math.min(activeIndex + 1, slots.length - 1);
154
+ scrollActiveIntoView();
155
+ }
156
+ break;
157
+ }
158
+ case "ArrowUp": {
159
+ event.preventDefault();
160
+ if (!open) {
161
+ openList();
162
+ } else {
163
+ // ArrowUp avec liste déjà ouverte → remonter d'une option.
164
+ activeIndex = Math.max(activeIndex - 1, 0);
165
+ scrollActiveIntoView();
166
+ }
167
+ break;
168
+ }
169
+ case "Home": {
170
+ event.preventDefault();
171
+ if (!open) {
172
+ openList();
173
+ } else {
174
+ activeIndex = 0;
175
+ scrollActiveIntoView();
176
+ }
177
+ break;
178
+ }
179
+ case "End": {
180
+ event.preventDefault();
181
+ if (!open) {
182
+ openList();
183
+ } else {
184
+ activeIndex = slots.length - 1;
185
+ scrollActiveIntoView();
186
+ }
187
+ break;
188
+ }
189
+ case "Enter":
190
+ case " ": {
191
+ event.preventDefault();
192
+ if (!open) {
193
+ openList();
194
+ } else {
195
+ // Enter / Space sur l'input avec liste déjà ouverte → sélectionner l'actif.
196
+ if (activeIndex >= 0 && activeIndex < slots.length) {
197
+ pick(slots[activeIndex]);
198
+ }
199
+ }
200
+ break;
201
+ }
202
+ case "Escape": {
203
+ if (open) {
204
+ event.preventDefault();
205
+ closeList(true);
206
+ }
207
+ break;
208
+ }
209
+ }
210
+ }
211
+
212
+ function onListKeyDown(event: KeyboardEvent) {
213
+ switch (event.key) {
214
+ case "ArrowDown": {
215
+ event.preventDefault();
216
+ activeIndex = Math.min(activeIndex + 1, slots.length - 1);
217
+ scrollActiveIntoView();
218
+ break;
219
+ }
220
+ case "ArrowUp": {
221
+ event.preventDefault();
222
+ activeIndex = Math.max(activeIndex - 1, 0);
223
+ scrollActiveIntoView();
224
+ break;
225
+ }
226
+ case "Home": {
227
+ event.preventDefault();
228
+ activeIndex = 0;
229
+ scrollActiveIntoView();
230
+ break;
231
+ }
232
+ case "End": {
233
+ event.preventDefault();
234
+ activeIndex = slots.length - 1;
235
+ scrollActiveIntoView();
236
+ break;
237
+ }
238
+ case "Enter":
239
+ case " ": {
240
+ event.preventDefault();
241
+ if (activeIndex >= 0 && activeIndex < slots.length) {
242
+ pick(slots[activeIndex]);
243
+ }
244
+ break;
245
+ }
246
+ case "Escape": {
247
+ event.preventDefault();
248
+ closeList(true);
249
+ break;
250
+ }
108
251
  }
109
252
  }
110
253
 
@@ -112,7 +255,7 @@
112
255
  if (!open) return;
113
256
  const target = event.target as Node | null;
114
257
  if (hostEl && target && !hostEl.contains(target)) {
115
- open = false;
258
+ closeList(false);
116
259
  }
117
260
  }
118
261
 
@@ -129,6 +272,7 @@
129
272
  <span class={groupClasses}>
130
273
  <input
131
274
  id={fieldId}
275
+ bind:this={inputEl}
132
276
  type="text"
133
277
  readonly
134
278
  class="st-timepicker__control"
@@ -139,7 +283,10 @@
139
283
  aria-haspopup="listbox"
140
284
  aria-controls={listId}
141
285
  aria-expanded={open ? "true" : "false"}
286
+ aria-activedescendant={activeDescendant}
287
+ aria-autocomplete="none"
142
288
  onclick={toggleOpen}
289
+ onkeydown={onInputKeyDown}
143
290
  />
144
291
  <button
145
292
  type="button"
@@ -147,6 +294,7 @@
147
294
  aria-label="Ouvrir la liste des horaires"
148
295
  aria-haspopup="listbox"
149
296
  aria-expanded={open ? "true" : "false"}
297
+ tabindex="-1"
150
298
  {disabled}
151
299
  onclick={toggleOpen}
152
300
  >
@@ -157,24 +305,33 @@
157
305
  {#if open}
158
306
  <ul
159
307
  id={listId}
308
+ bind:this={listEl}
160
309
  class="st-timepicker__list"
161
310
  role="listbox"
162
311
  aria-label={label ?? "Horaires"}
163
312
  tabindex="-1"
164
- onkeydown={onPanelKeyDown}
313
+ onkeydown={onListKeyDown}
165
314
  >
166
- {#each slots as slot (slot)}
315
+ {#each slots as slot, i (slot)}
167
316
  <li role="presentation">
168
- <button
169
- type="button"
317
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
318
+ <!-- Faux positif : la navigation clavier est gérée par le combobox via
319
+ aria-activedescendant + onkeydown sur le listbox parent (onListKeyDown).
320
+ Les options n'ont pas besoin de leur propre gestionnaire keydown. -->
321
+ <div
322
+ id="{listId}-opt-{i}"
170
323
  class="st-timepicker__option"
171
324
  class:st-timepicker__option--selected={slot === value}
325
+ class:st-timepicker__option--active={i === activeIndex}
172
326
  role="option"
173
327
  aria-selected={slot === value ? "true" : "false"}
174
- onclick={() => pick(slot)}
328
+ tabindex="-1"
329
+ onmousedown={(e) => { e.preventDefault(); }}
330
+ onclick={() => { pick(slot); }}
331
+ onmouseenter={() => { activeIndex = i; }}
175
332
  >
176
333
  {display(slot)}
177
- </button>
334
+ </div>
178
335
  </li>
179
336
  {/each}
180
337
  </ul>
@@ -332,4 +489,10 @@
332
489
  background: var(--st-component-dropdown-selectedBackground, var(--st-semantic-action-primary));
333
490
  color: var(--st-component-dropdown-selectedText, var(--st-semantic-action-primaryText));
334
491
  }
492
+
493
+ .st-timepicker__option--active:not(.st-timepicker__option--selected) {
494
+ background: var(--st-component-control-hoverBackground, var(--st-semantic-surface-subtle));
495
+ outline: 2px solid var(--st-component-control-focusRing, var(--st-semantic-border-interactive));
496
+ outline-offset: -2px;
497
+ }
335
498
  </style>
@@ -1 +1 @@
1
- {"version":3,"file":"TimePicker.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TimePicker.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,gBAAgB,GAAG,IAAI,GAAG,IAAI,CAAC;AAI7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,eAAe,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAClF,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAoIJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"TimePicker.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TimePicker.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,gBAAgB,GAAG,IAAI,GAAG,IAAI,CAAC;AAI7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,eAAe,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAClF,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAqRJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/design-system-svelte",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"