@sentropic/design-system-svelte 0.17.0 → 0.19.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/BoxPlotChart.svelte +302 -0
  2. package/dist/BoxPlotChart.svelte.d.ts +40 -0
  3. package/dist/BoxPlotChart.svelte.d.ts.map +1 -0
  4. package/dist/Calendar.svelte +237 -42
  5. package/dist/Calendar.svelte.d.ts.map +1 -1
  6. package/dist/HeatmapChart.svelte +337 -0
  7. package/dist/HeatmapChart.svelte.d.ts +35 -0
  8. package/dist/HeatmapChart.svelte.d.ts.map +1 -0
  9. package/dist/HistogramChart.svelte +294 -0
  10. package/dist/HistogramChart.svelte.d.ts +38 -0
  11. package/dist/HistogramChart.svelte.d.ts.map +1 -0
  12. package/dist/Popper.svelte +157 -0
  13. package/dist/Popper.svelte.d.ts +17 -0
  14. package/dist/Popper.svelte.d.ts.map +1 -1
  15. package/dist/RadarChart.svelte +340 -0
  16. package/dist/RadarChart.svelte.d.ts +43 -0
  17. package/dist/RadarChart.svelte.d.ts.map +1 -0
  18. package/dist/Rating.svelte +130 -35
  19. package/dist/Rating.svelte.d.ts.map +1 -1
  20. package/dist/SankeyChart.svelte +364 -0
  21. package/dist/SankeyChart.svelte.d.ts +45 -0
  22. package/dist/SankeyChart.svelte.d.ts.map +1 -0
  23. package/dist/SelectableList.svelte +60 -12
  24. package/dist/SelectableList.svelte.d.ts.map +1 -1
  25. package/dist/SelectableRow.svelte +23 -8
  26. package/dist/SelectableRow.svelte.d.ts +5 -4
  27. package/dist/SelectableRow.svelte.d.ts.map +1 -1
  28. package/dist/SlideIndicator.svelte +17 -3
  29. package/dist/SlideIndicator.svelte.d.ts.map +1 -1
  30. package/dist/SunburstChart.svelte +388 -0
  31. package/dist/SunburstChart.svelte.d.ts +39 -0
  32. package/dist/SunburstChart.svelte.d.ts.map +1 -0
  33. package/dist/TimePicker.svelte +176 -13
  34. package/dist/TimePicker.svelte.d.ts.map +1 -1
  35. package/dist/chartContrast.d.ts +0 -4
  36. package/dist/chartContrast.d.ts.map +1 -1
  37. package/dist/chartContrast.js +4 -56
  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
@@ -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"}
@@ -0,0 +1,388 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * SunburstChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
4
+ *
5
+ * Props obligatoires :
6
+ * data SunburstChartDatum - nœud racine (label + children récursifs)
7
+ * label string - aria-label du graphique
8
+ *
9
+ * Props optionnelles :
10
+ * legend boolean (défaut false) - affiche la légende des top-level children
11
+ * width number (défaut 320) - largeur du viewBox en px
12
+ * height number (défaut 320) - hauteur du viewBox en px
13
+ * class string - classe CSS supplémentaire
14
+ *
15
+ * Labels d'arcs :
16
+ * Affichés sur les arcs de span > 0.28 rad. Couleur de texte calculée par
17
+ * luminance (via chartContrast.ts) pour garantir le contraste WCAG AA sur
18
+ * chaque fond catégoriel - pas de texte blanc fixe.
19
+ *
20
+ * Infobulle : format "parent, enfant" (séparateur ", ") - cohérent avec lot 1.
21
+ */
22
+ export type SunburstChartTone =
23
+ | "category1"
24
+ | "category2"
25
+ | "category3"
26
+ | "category4"
27
+ | "category5"
28
+ | "category6"
29
+ | "category7"
30
+ | "category8";
31
+
32
+ export type SunburstChartDatum = {
33
+ label: string;
34
+ value?: number;
35
+ tone?: SunburstChartTone;
36
+ children?: SunburstChartDatum[];
37
+ };
38
+ </script>
39
+
40
+ <script lang="ts">
41
+ import ChartDataList from "./ChartDataList.svelte";
42
+ import { contrastTextForTone } from "./chartContrast.js";
43
+
44
+ type SunburstChartProps = {
45
+ data: SunburstChartDatum;
46
+ label: string;
47
+ legend?: boolean;
48
+ width?: number;
49
+ height?: number;
50
+ class?: string;
51
+ };
52
+
53
+ type ArcDatum = {
54
+ datum: SunburstChartDatum;
55
+ pathLabel: string[];
56
+ value: number;
57
+ tone: SunburstChartTone;
58
+ depth: number;
59
+ start: number;
60
+ end: number;
61
+ path: string;
62
+ labelX: number;
63
+ labelY: number;
64
+ };
65
+
66
+ let {
67
+ data,
68
+ label,
69
+ legend = false,
70
+ width = 320,
71
+ height = 320,
72
+ class: className
73
+ }: SunburstChartProps = $props();
74
+
75
+ const TONES = [
76
+ "category1",
77
+ "category2",
78
+ "category3",
79
+ "category4",
80
+ "category5",
81
+ "category6",
82
+ "category7",
83
+ "category8"
84
+ ] as const;
85
+
86
+ function leafValue(value: number | undefined): number {
87
+ return Number.isFinite(value) && (value ?? 0) > 0 ? (value as number) : 0;
88
+ }
89
+
90
+ function sumValue(node: SunburstChartDatum): number {
91
+ if (node.children && node.children.length > 0) {
92
+ return node.children.reduce((sum, child) => sum + sumValue(child), 0);
93
+ }
94
+ return leafValue(node.value);
95
+ }
96
+
97
+ function maxDepth(node: SunburstChartDatum, depth = 0): number {
98
+ if (!node.children || node.children.length === 0) return depth;
99
+ return Math.max(depth, ...node.children.map((child) => maxDepth(child, depth + 1)));
100
+ }
101
+
102
+ function point(cx: number, cy: number, radius: number, angle: number) {
103
+ return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
104
+ }
105
+
106
+ function arcPath(cx: number, cy: number, innerRadius: number, outerRadius: number, start: number, end: number): string {
107
+ const safeEnd = Math.min(end, start + Math.PI * 2 - 0.0001);
108
+ const large = safeEnd - start > Math.PI ? 1 : 0;
109
+ const outerStart = point(cx, cy, outerRadius, start);
110
+ const outerEnd = point(cx, cy, outerRadius, safeEnd);
111
+
112
+ if (innerRadius <= 0) {
113
+ return `M ${cx} ${cy} L ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y} Z`;
114
+ }
115
+
116
+ const innerEnd = point(cx, cy, innerRadius, safeEnd);
117
+ const innerStart = point(cx, cy, innerRadius, start);
118
+ return `M ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y} L ${innerEnd.x} ${innerEnd.y} A ${innerRadius} ${innerRadius} 0 ${large} 0 ${innerStart.x} ${innerStart.y} Z`;
119
+ }
120
+
121
+ let hoveredIndex: number | null = $state(null);
122
+
123
+ const arcs = $derived.by<ArcDatum[]>(() => {
124
+ const total = sumValue(data);
125
+ if (total <= 0 || !data.children || data.children.length === 0) return [];
126
+
127
+ const cx = width / 2;
128
+ const cy = height / 2;
129
+ const ringCount = Math.max(1, maxDepth(data));
130
+ const outerLimit = Math.max(Math.min(width, height) / 2 - 6, 1);
131
+ const ring = outerLimit / ringCount;
132
+ const out: ArcDatum[] = [];
133
+
134
+ function visit(
135
+ node: SunburstChartDatum,
136
+ depth: number,
137
+ start: number,
138
+ end: number,
139
+ pathLabel: string[],
140
+ inheritedTone: SunburstChartTone,
141
+ siblingIndex: number
142
+ ) {
143
+ if (depth > 0) {
144
+ const tone = node.tone ?? inheritedTone ?? TONES[siblingIndex % TONES.length];
145
+ const innerRadius = (depth - 1) * ring;
146
+ const outerRadius = depth * ring;
147
+ const midAngle = (start + end) / 2;
148
+ const midRadius = (innerRadius + outerRadius) / 2;
149
+ const labelPoint = point(cx, cy, midRadius, midAngle);
150
+ out.push({
151
+ datum: node,
152
+ pathLabel,
153
+ value: sumValue(node),
154
+ tone,
155
+ depth,
156
+ start,
157
+ end,
158
+ path: arcPath(cx, cy, innerRadius, outerRadius, start, end),
159
+ labelX: labelPoint.x,
160
+ labelY: labelPoint.y
161
+ });
162
+ }
163
+
164
+ const children = node.children ?? [];
165
+ const nodeTotal = children.reduce((sum, child) => sum + sumValue(child), 0);
166
+ if (children.length === 0 || nodeTotal <= 0) return;
167
+
168
+ let cursor = start;
169
+ children.forEach((child, childIndex) => {
170
+ const value = sumValue(child);
171
+ if (value <= 0) return;
172
+ const span = ((end - start) * value) / nodeTotal;
173
+ const tone = child.tone ?? (depth === 0 ? TONES[childIndex % TONES.length] : inheritedTone);
174
+ visit(child, depth + 1, cursor, cursor + span, [...pathLabel, child.label], tone, childIndex);
175
+ cursor += span;
176
+ });
177
+ }
178
+
179
+ visit(data, 0, -Math.PI / 2, Math.PI * 1.5, [data.label], "category1", 0);
180
+ return out;
181
+ });
182
+
183
+ const leafItems = $derived.by(() => {
184
+ const items: string[] = [];
185
+ function collect(node: SunburstChartDatum, path: string[]) {
186
+ if (node.children && node.children.length > 0) {
187
+ node.children.forEach((child) => collect(child, [...path, child.label]));
188
+ return;
189
+ }
190
+ items.push(`${path.join(", ")}: ${leafValue(node.value)}`);
191
+ }
192
+ collect(data, [data.label]);
193
+ return items.filter((item) => !item.endsWith(": 0"));
194
+ });
195
+
196
+ const legendItems = $derived(
197
+ (data.children ?? []).map((child, index) => ({
198
+ label: child.label,
199
+ tone: child.tone ?? TONES[index % TONES.length]
200
+ }))
201
+ );
202
+
203
+ function handleVisualPointerMove(event: PointerEvent) {
204
+ const target = event.target;
205
+ if (!(target instanceof Element)) {
206
+ hoveredIndex = null;
207
+ return;
208
+ }
209
+ const index = Number(target.getAttribute("data-chart-index"));
210
+ hoveredIndex = Number.isInteger(index) ? index : null;
211
+ }
212
+
213
+ const classes = () => ["st-sunburstChart", className].filter(Boolean).join(" ");
214
+ </script>
215
+
216
+ <div class={classes()}>
217
+ <div
218
+ class="st-sunburstChart__visual"
219
+ role="img"
220
+ aria-label={label}
221
+ onpointermove={handleVisualPointerMove}
222
+ onpointerleave={() => (hoveredIndex = null)}
223
+ >
224
+ <svg
225
+ viewBox="0 0 {width} {height}"
226
+ preserveAspectRatio="xMidYMid meet"
227
+ width="100%"
228
+ height="100%"
229
+ focusable="false"
230
+ aria-hidden="true"
231
+ >
232
+ {#each arcs as arc, i (arc.pathLabel.join("/"))}
233
+ <path
234
+ class="st-sunburstChart__arc st-sunburstChart__arc--{arc.tone}"
235
+ class:st-sunburstChart__arc--dim={hoveredIndex !== null && hoveredIndex !== i}
236
+ d={arc.path}
237
+ data-chart-index={i}
238
+ />
239
+ {/each}
240
+
241
+ {#each arcs as arc (arc.pathLabel.join("/"))}
242
+ {#if arc.end - arc.start > 0.28}
243
+ <text
244
+ class="st-sunburstChart__label"
245
+ x={arc.labelX}
246
+ y={arc.labelY}
247
+ text-anchor="middle"
248
+ dominant-baseline="middle"
249
+ fill={contrastTextForTone(arc.tone)}
250
+ >
251
+ {arc.datum.label}
252
+ </text>
253
+ {/if}
254
+ {/each}
255
+ </svg>
256
+ </div>
257
+
258
+ <ChartDataList {label} items={leafItems} />
259
+
260
+ {#if hoveredIndex !== null && arcs[hoveredIndex]}
261
+ {@const arc = arcs[hoveredIndex]}
262
+ <div
263
+ class="st-sunburstChart__tooltip"
264
+ role="presentation"
265
+ style="left: {(arc.labelX / width) * 100}%; top: {(arc.labelY / height) * 100}%"
266
+ >
267
+ <span class="st-sunburstChart__tooltipLabel">{arc.pathLabel.join(", ")}</span>
268
+ <span class="st-sunburstChart__tooltipValue">{arc.value}</span>
269
+ </div>
270
+ {/if}
271
+
272
+ {#if legend && legendItems.length > 0}
273
+ <ul class="st-sunburstChart__legend" aria-hidden="true">
274
+ {#each legendItems as item (item.label)}
275
+ <li class="st-sunburstChart__legendItem">
276
+ <span class="st-sunburstChart__legendSwatch st-sunburstChart__legendSwatch--{item.tone}"></span>
277
+ {item.label}
278
+ </li>
279
+ {/each}
280
+ </ul>
281
+ {/if}
282
+ </div>
283
+
284
+ <style>
285
+ .st-sunburstChart {
286
+ color: var(--st-semantic-text-secondary);
287
+ display: block;
288
+ font-family: inherit;
289
+ max-width: 100%;
290
+ position: relative;
291
+ width: 100%;
292
+ }
293
+
294
+ .st-sunburstChart svg,
295
+ .st-sunburstChart__visual {
296
+ display: block;
297
+ overflow: visible;
298
+ }
299
+
300
+ .st-sunburstChart__arc {
301
+ cursor: pointer;
302
+ stroke: var(--st-semantic-surface-default, Canvas);
303
+ stroke-width: 1;
304
+ transition: opacity 120ms ease;
305
+ }
306
+
307
+ .st-sunburstChart__arc--dim {
308
+ opacity: 0.4;
309
+ }
310
+
311
+ .st-sunburstChart__arc--category1,
312
+ .st-sunburstChart__legendSwatch--category1 { fill: var(--st-semantic-data-category1); background: var(--st-semantic-data-category1); }
313
+ .st-sunburstChart__arc--category2,
314
+ .st-sunburstChart__legendSwatch--category2 { fill: var(--st-semantic-data-category2); background: var(--st-semantic-data-category2); }
315
+ .st-sunburstChart__arc--category3,
316
+ .st-sunburstChart__legendSwatch--category3 { fill: var(--st-semantic-data-category3); background: var(--st-semantic-data-category3); }
317
+ .st-sunburstChart__arc--category4,
318
+ .st-sunburstChart__legendSwatch--category4 { fill: var(--st-semantic-data-category4); background: var(--st-semantic-data-category4); }
319
+ .st-sunburstChart__arc--category5,
320
+ .st-sunburstChart__legendSwatch--category5 { fill: var(--st-semantic-data-category5); background: var(--st-semantic-data-category5); }
321
+ .st-sunburstChart__arc--category6,
322
+ .st-sunburstChart__legendSwatch--category6 { fill: var(--st-semantic-data-category6); background: var(--st-semantic-data-category6); }
323
+ .st-sunburstChart__arc--category7,
324
+ .st-sunburstChart__legendSwatch--category7 { fill: var(--st-semantic-data-category7); background: var(--st-semantic-data-category7); }
325
+ .st-sunburstChart__arc--category8,
326
+ .st-sunburstChart__legendSwatch--category8 { fill: var(--st-semantic-data-category8); background: var(--st-semantic-data-category8); }
327
+
328
+ .st-sunburstChart__label {
329
+ /* fill calculé par contrastTextForTone() en inline - pas de blanc fixe */
330
+ font-size: 0.68rem;
331
+ font-weight: 650;
332
+ pointer-events: none;
333
+ }
334
+
335
+ @media (prefers-reduced-motion: reduce) {
336
+ .st-sunburstChart__arc {
337
+ transition: none;
338
+ }
339
+ }
340
+
341
+ .st-sunburstChart__legend {
342
+ display: flex;
343
+ flex-wrap: wrap;
344
+ gap: var(--st-spacing-2, 0.5rem) var(--st-spacing-4, 1rem);
345
+ list-style: none;
346
+ margin: var(--st-spacing-2, 0.5rem) 0 0;
347
+ padding: 0;
348
+ }
349
+
350
+ .st-sunburstChart__legendItem {
351
+ align-items: center;
352
+ color: var(--st-semantic-text-secondary);
353
+ display: inline-flex;
354
+ font-size: 0.75rem;
355
+ gap: var(--st-spacing-2, 0.5rem);
356
+ }
357
+
358
+ .st-sunburstChart__legendSwatch {
359
+ display: inline-block;
360
+ height: 0.625rem;
361
+ width: 0.625rem;
362
+ }
363
+
364
+ .st-sunburstChart__tooltip {
365
+ background: var(--st-semantic-surface-inverse);
366
+ border-radius: var(--st-radius-sm, 0.25rem);
367
+ color: var(--st-semantic-text-inverse);
368
+ display: inline-flex;
369
+ flex-direction: column;
370
+ font-size: 0.75rem;
371
+ gap: 0.125rem;
372
+ line-height: 1.2;
373
+ padding: 0.375rem 0.5rem;
374
+ pointer-events: none;
375
+ position: absolute;
376
+ transform: translate(-50%, -115%);
377
+ white-space: nowrap;
378
+ z-index: 1;
379
+ }
380
+
381
+ .st-sunburstChart__tooltipLabel {
382
+ font-weight: 600;
383
+ }
384
+
385
+ .st-sunburstChart__tooltipValue {
386
+ opacity: 0.85;
387
+ }
388
+ </style>