@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.
- package/dist/Calendar.svelte +237 -42
- package/dist/Calendar.svelte.d.ts.map +1 -1
- package/dist/Popper.svelte +157 -0
- package/dist/Popper.svelte.d.ts +17 -0
- package/dist/Popper.svelte.d.ts.map +1 -1
- package/dist/Rating.svelte +130 -35
- package/dist/Rating.svelte.d.ts.map +1 -1
- package/dist/SelectableList.svelte +60 -12
- package/dist/SelectableList.svelte.d.ts.map +1 -1
- package/dist/SelectableRow.svelte +23 -8
- package/dist/SelectableRow.svelte.d.ts +5 -4
- package/dist/SelectableRow.svelte.d.ts.map +1 -1
- package/dist/SlideIndicator.svelte +17 -3
- package/dist/SlideIndicator.svelte.d.ts.map +1 -1
- package/dist/TimePicker.svelte +176 -13
- package/dist/TimePicker.svelte.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/Calendar.svelte
CHANGED
|
@@ -173,22 +173,56 @@
|
|
|
173
173
|
return d > rangeStart.getTime() && d < rangeEnd.getTime();
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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;
|
|
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"}
|
package/dist/Popper.svelte
CHANGED
|
@@ -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}
|
package/dist/Popper.svelte.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/Rating.svelte
CHANGED
|
@@ -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
|
-
|
|
105
|
+
next = Math.min(max, value + step);
|
|
92
106
|
break;
|
|
93
107
|
case "ArrowLeft":
|
|
94
108
|
case "ArrowDown":
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
next = max;
|
|
102
118
|
break;
|
|
103
119
|
default:
|
|
104
120
|
handled = false;
|
|
105
121
|
}
|
|
106
|
-
if (handled)
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
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
|
-
|
|
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([
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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;
|
|
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 "
|
|
48
|
-
*
|
|
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 = "
|
|
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.
|
|
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
|
|
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 "
|
|
44
|
-
*
|
|
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,
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/TimePicker.svelte
CHANGED
|
@@ -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
|
|
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
|
-
|
|
132
|
+
closeList(true);
|
|
102
133
|
}
|
|
103
134
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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={
|
|
313
|
+
onkeydown={onListKeyDown}
|
|
165
314
|
>
|
|
166
|
-
{#each slots as slot (slot)}
|
|
315
|
+
{#each slots as slot, i (slot)}
|
|
167
316
|
<li role="presentation">
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
328
|
+
tabindex="-1"
|
|
329
|
+
onmousedown={(e) => { e.preventDefault(); }}
|
|
330
|
+
onclick={() => { pick(slot); }}
|
|
331
|
+
onmouseenter={() => { activeIndex = i; }}
|
|
175
332
|
>
|
|
176
333
|
{display(slot)}
|
|
177
|
-
</
|
|
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;
|
|
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"}
|