@sentropic/design-system-svelte 0.16.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.
Files changed (41) hide show
  1. package/dist/Calendar.svelte +237 -42
  2. package/dist/Calendar.svelte.d.ts.map +1 -1
  3. package/dist/ComboChart.svelte +620 -0
  4. package/dist/ComboChart.svelte.d.ts +28 -0
  5. package/dist/ComboChart.svelte.d.ts.map +1 -0
  6. package/dist/FunnelChart.svelte +358 -0
  7. package/dist/FunnelChart.svelte.d.ts +21 -0
  8. package/dist/FunnelChart.svelte.d.ts.map +1 -0
  9. package/dist/GaugeChart.svelte +300 -0
  10. package/dist/GaugeChart.svelte.d.ts +36 -0
  11. package/dist/GaugeChart.svelte.d.ts.map +1 -0
  12. package/dist/KpiCard.svelte +318 -0
  13. package/dist/KpiCard.svelte.d.ts +36 -0
  14. package/dist/KpiCard.svelte.d.ts.map +1 -0
  15. package/dist/Popper.svelte +157 -0
  16. package/dist/Popper.svelte.d.ts +17 -0
  17. package/dist/Popper.svelte.d.ts.map +1 -1
  18. package/dist/Rating.svelte +130 -35
  19. package/dist/Rating.svelte.d.ts.map +1 -1
  20. package/dist/SelectableList.svelte +60 -12
  21. package/dist/SelectableList.svelte.d.ts.map +1 -1
  22. package/dist/SelectableRow.svelte +23 -8
  23. package/dist/SelectableRow.svelte.d.ts +5 -4
  24. package/dist/SelectableRow.svelte.d.ts.map +1 -1
  25. package/dist/SlideIndicator.svelte +17 -3
  26. package/dist/SlideIndicator.svelte.d.ts.map +1 -1
  27. package/dist/TimePicker.svelte +176 -13
  28. package/dist/TimePicker.svelte.d.ts.map +1 -1
  29. package/dist/TreemapChart.svelte +448 -0
  30. package/dist/TreemapChart.svelte.d.ts +26 -0
  31. package/dist/TreemapChart.svelte.d.ts.map +1 -0
  32. package/dist/WaterfallChart.svelte +469 -0
  33. package/dist/WaterfallChart.svelte.d.ts +19 -0
  34. package/dist/WaterfallChart.svelte.d.ts.map +1 -0
  35. package/dist/chartContrast.d.ts +6 -0
  36. package/dist/chartContrast.d.ts.map +1 -0
  37. package/dist/chartContrast.js +58 -0
  38. package/dist/index.d.ts +12 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +6 -0
  41. package/package.json +1 -1
@@ -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"}