@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
@@ -0,0 +1,302 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * BoxPlotChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
4
+ *
5
+ * Props obligatoires :
6
+ * data BoxPlotChartDatum[] - tableau {label, min, q1, median, q3, max, outliers?, tone?}
7
+ * label string - aria-label du graphique
8
+ *
9
+ * Props optionnelles :
10
+ * width number (défaut 480) - largeur du viewBox en px
11
+ * height number (défaut 260) - hauteur du viewBox en px
12
+ * class string - classe CSS supplémentaire
13
+ *
14
+ * NaN/vide : les valeurs non-finies sont exclues du domaine (filter Number.isFinite).
15
+ * Tableau vide → rendu vide sans crash.
16
+ * Note : l'ordre min≤q1≤median≤q3≤max n'est pas imposé par clamp mais rendu
17
+ * tel quel (abs() sur la hauteur de boîte) ; les données incohérentes produisent
18
+ * un rendu plausible mais peuvent induire en erreur.
19
+ */
20
+ export type BoxPlotChartTone =
21
+ | "category1"
22
+ | "category2"
23
+ | "category3"
24
+ | "category4"
25
+ | "category5"
26
+ | "category6"
27
+ | "category7"
28
+ | "category8";
29
+
30
+ export type BoxPlotChartDatum = {
31
+ label: string;
32
+ min: number;
33
+ q1: number;
34
+ median: number;
35
+ q3: number;
36
+ max: number;
37
+ outliers?: number[];
38
+ tone?: BoxPlotChartTone;
39
+ };
40
+ </script>
41
+
42
+ <script lang="ts">
43
+ import ChartDataList from "./ChartDataList.svelte";
44
+
45
+ type BoxPlotChartProps = {
46
+ data: BoxPlotChartDatum[];
47
+ label: string;
48
+ width?: number;
49
+ height?: number;
50
+ class?: string;
51
+ };
52
+
53
+ let {
54
+ data,
55
+ label,
56
+ width = 480,
57
+ height = 260,
58
+ class: className
59
+ }: BoxPlotChartProps = $props();
60
+
61
+ const MARGIN = { top: 16, right: 20, bottom: 38, left: 48 };
62
+ const TONES = [
63
+ "category1",
64
+ "category2",
65
+ "category3",
66
+ "category4",
67
+ "category5",
68
+ "category6",
69
+ "category7",
70
+ "category8"
71
+ ] as const;
72
+
73
+ function formatNumber(value: number): string {
74
+ if (!Number.isFinite(value)) return "0";
75
+ if (Number.isInteger(value)) return String(value);
76
+ return value.toFixed(2).replace(/\.?0+$/, "");
77
+ }
78
+
79
+ function scaleLinear(v: number, d0: number, d1: number, r0: number, r1: number) {
80
+ if (d1 === d0) return r0;
81
+ return r0 + ((v - d0) * (r1 - r0)) / (d1 - d0);
82
+ }
83
+
84
+ let hoveredIndex: number | null = $state(null);
85
+
86
+ const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
87
+ const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
88
+
89
+ const domain = $derived.by(() => {
90
+ const values = data.flatMap((datum) => [
91
+ datum.min,
92
+ datum.q1,
93
+ datum.median,
94
+ datum.q3,
95
+ datum.max,
96
+ ...(datum.outliers ?? [])
97
+ ]).filter(Number.isFinite);
98
+ if (values.length === 0) return { min: 0, max: 1 };
99
+ const min = Math.min(...values);
100
+ const max = Math.max(...values);
101
+ const pad = (max - min) * 0.08 || Math.max(Math.abs(max), 1) * 0.1;
102
+ return { min: min - pad, max: max + pad };
103
+ });
104
+
105
+ const plots = $derived.by(() => {
106
+ const band = data.length > 0 ? plotWidth / data.length : plotWidth;
107
+ const boxWidth = Math.min(54, Math.max(18, band * 0.44));
108
+
109
+ return data.map((datum, index) => {
110
+ const cx = MARGIN.left + band * (index + 0.5);
111
+ const y = (value: number) => MARGIN.top + scaleLinear(value, domain.min, domain.max, plotHeight, 0);
112
+ const q1Y = y(datum.q1);
113
+ const q3Y = y(datum.q3);
114
+ const minY = y(datum.min);
115
+ const maxY = y(datum.max);
116
+ return {
117
+ datum,
118
+ tone: datum.tone ?? TONES[index % TONES.length],
119
+ cx,
120
+ boxX: cx - boxWidth / 2,
121
+ boxY: Math.min(q1Y, q3Y),
122
+ boxWidth,
123
+ boxHeight: Math.max(Math.abs(q1Y - q3Y), 1),
124
+ medianY: y(datum.median),
125
+ minY,
126
+ maxY,
127
+ capWidth: boxWidth * 0.72,
128
+ outliers: (datum.outliers ?? []).filter(Number.isFinite).map((value) => ({ value, y: y(value) }))
129
+ };
130
+ });
131
+ });
132
+
133
+ const dataValueItems = $derived(
134
+ data.map((datum) => {
135
+ const summary = `${datum.label}: min ${formatNumber(datum.min)}, q1 ${formatNumber(datum.q1)}, median ${formatNumber(datum.median)}, q3 ${formatNumber(datum.q3)}, max ${formatNumber(datum.max)}`;
136
+ const outliers = datum.outliers?.length ? `, outliers ${datum.outliers.map(formatNumber).join(", ")}` : "";
137
+ return `${summary}${outliers}`;
138
+ })
139
+ );
140
+
141
+ function handleVisualPointerMove(event: PointerEvent) {
142
+ const target = event.target;
143
+ if (!(target instanceof Element)) {
144
+ hoveredIndex = null;
145
+ return;
146
+ }
147
+ const index = Number(target.getAttribute("data-chart-index"));
148
+ hoveredIndex = Number.isInteger(index) ? index : null;
149
+ }
150
+
151
+ const classes = () => ["st-boxPlotChart", className].filter(Boolean).join(" ");
152
+ </script>
153
+
154
+ <div class={classes()}>
155
+ <div
156
+ class="st-boxPlotChart__visual"
157
+ role="img"
158
+ aria-label={label}
159
+ onpointermove={handleVisualPointerMove}
160
+ onpointerleave={() => (hoveredIndex = null)}
161
+ >
162
+ <svg
163
+ viewBox="0 0 {width} {height}"
164
+ preserveAspectRatio="xMidYMid meet"
165
+ width="100%"
166
+ height="100%"
167
+ focusable="false"
168
+ aria-hidden="true"
169
+ >
170
+ <line class="st-boxPlotChart__axis" x1={MARGIN.left} x2={MARGIN.left} y1={MARGIN.top} y2={height - MARGIN.bottom} />
171
+ <line class="st-boxPlotChart__axis" x1={MARGIN.left} x2={width - MARGIN.right} y1={height - MARGIN.bottom} y2={height - MARGIN.bottom} />
172
+
173
+ {#each plots as plot, i (plot.datum.label)}
174
+ <line class="st-boxPlotChart__whisker" x1={plot.cx} x2={plot.cx} y1={plot.minY} y2={plot.maxY} data-chart-index={i} />
175
+ <line class="st-boxPlotChart__whiskerCap" x1={plot.cx - plot.capWidth / 2} x2={plot.cx + plot.capWidth / 2} y1={plot.minY} y2={plot.minY} data-chart-index={i} />
176
+ <line class="st-boxPlotChart__whiskerCap" x1={plot.cx - plot.capWidth / 2} x2={plot.cx + plot.capWidth / 2} y1={plot.maxY} y2={plot.maxY} data-chart-index={i} />
177
+ <rect
178
+ class="st-boxPlotChart__box st-boxPlotChart__box--{plot.tone}"
179
+ class:st-boxPlotChart__box--dim={hoveredIndex !== null && hoveredIndex !== i}
180
+ x={plot.boxX}
181
+ y={plot.boxY}
182
+ width={plot.boxWidth}
183
+ height={plot.boxHeight}
184
+ data-chart-index={i}
185
+ />
186
+ <line class="st-boxPlotChart__median" x1={plot.boxX} x2={plot.boxX + plot.boxWidth} y1={plot.medianY} y2={plot.medianY} data-chart-index={i} />
187
+ {#each plot.outliers as outlier (`${plot.datum.label}-${outlier.value}`)}
188
+ <circle class="st-boxPlotChart__outlier" cx={plot.cx} cy={outlier.y} r="3" data-chart-index={i} />
189
+ {/each}
190
+ <text class="st-boxPlotChart__label" x={plot.cx} y={height - MARGIN.bottom + 16} text-anchor="middle">
191
+ {plot.datum.label}
192
+ </text>
193
+ {/each}
194
+ </svg>
195
+ </div>
196
+
197
+ <ChartDataList {label} items={dataValueItems} />
198
+
199
+ {#if hoveredIndex !== null && plots[hoveredIndex]}
200
+ {@const plot = plots[hoveredIndex]}
201
+ <div
202
+ class="st-boxPlotChart__tooltip"
203
+ role="presentation"
204
+ style="left: {(plot.cx / width) * 100}%; top: {(plot.medianY / height) * 100}%"
205
+ >
206
+ <span class="st-boxPlotChart__tooltipLabel">{plot.datum.label}</span>
207
+ <span class="st-boxPlotChart__tooltipValue">Median {formatNumber(plot.datum.median)}</span>
208
+ </div>
209
+ {/if}
210
+ </div>
211
+
212
+ <style>
213
+ .st-boxPlotChart {
214
+ color: var(--st-semantic-text-secondary);
215
+ display: block;
216
+ font-family: inherit;
217
+ max-width: 100%;
218
+ position: relative;
219
+ width: 100%;
220
+ }
221
+
222
+ .st-boxPlotChart svg,
223
+ .st-boxPlotChart__visual {
224
+ display: block;
225
+ overflow: visible;
226
+ }
227
+
228
+ .st-boxPlotChart__axis,
229
+ .st-boxPlotChart__whisker,
230
+ .st-boxPlotChart__whiskerCap {
231
+ stroke: var(--st-semantic-border-subtle);
232
+ stroke-width: 1;
233
+ }
234
+
235
+ .st-boxPlotChart__median {
236
+ stroke: var(--st-semantic-text-primary);
237
+ stroke-width: 2;
238
+ }
239
+
240
+ .st-boxPlotChart__box {
241
+ cursor: pointer;
242
+ fill-opacity: 0.72;
243
+ stroke: var(--st-semantic-surface-default, Canvas);
244
+ stroke-width: 1;
245
+ transition: opacity 120ms ease;
246
+ }
247
+
248
+ .st-boxPlotChart__box--dim {
249
+ opacity: 0.45;
250
+ }
251
+
252
+ @media (prefers-reduced-motion: reduce) {
253
+ .st-boxPlotChart__box {
254
+ transition: none;
255
+ }
256
+ }
257
+
258
+ .st-boxPlotChart__box--category1 { fill: var(--st-semantic-data-category1); }
259
+ .st-boxPlotChart__box--category2 { fill: var(--st-semantic-data-category2); }
260
+ .st-boxPlotChart__box--category3 { fill: var(--st-semantic-data-category3); }
261
+ .st-boxPlotChart__box--category4 { fill: var(--st-semantic-data-category4); }
262
+ .st-boxPlotChart__box--category5 { fill: var(--st-semantic-data-category5); }
263
+ .st-boxPlotChart__box--category6 { fill: var(--st-semantic-data-category6); }
264
+ .st-boxPlotChart__box--category7 { fill: var(--st-semantic-data-category7); }
265
+ .st-boxPlotChart__box--category8 { fill: var(--st-semantic-data-category8); }
266
+
267
+ .st-boxPlotChart__outlier {
268
+ fill: var(--st-semantic-surface-default, Canvas);
269
+ stroke: var(--st-semantic-text-secondary);
270
+ stroke-width: 1.5;
271
+ }
272
+
273
+ .st-boxPlotChart__label {
274
+ fill: var(--st-semantic-text-secondary);
275
+ font-size: 0.75rem;
276
+ }
277
+
278
+ .st-boxPlotChart__tooltip {
279
+ background: var(--st-semantic-surface-inverse);
280
+ border-radius: var(--st-radius-sm, 0.25rem);
281
+ color: var(--st-semantic-text-inverse);
282
+ display: inline-flex;
283
+ flex-direction: column;
284
+ font-size: 0.75rem;
285
+ gap: 0.125rem;
286
+ line-height: 1.2;
287
+ padding: 0.375rem 0.5rem;
288
+ pointer-events: none;
289
+ position: absolute;
290
+ transform: translate(-50%, -115%);
291
+ white-space: nowrap;
292
+ z-index: 1;
293
+ }
294
+
295
+ .st-boxPlotChart__tooltipLabel {
296
+ font-weight: 600;
297
+ }
298
+
299
+ .st-boxPlotChart__tooltipValue {
300
+ opacity: 0.85;
301
+ }
302
+ </style>
@@ -0,0 +1,40 @@
1
+ /**
2
+ * BoxPlotChart - API canonique (référence Svelte, React/Vue doivent s'aligner)
3
+ *
4
+ * Props obligatoires :
5
+ * data BoxPlotChartDatum[] - tableau {label, min, q1, median, q3, max, outliers?, tone?}
6
+ * label string - aria-label du graphique
7
+ *
8
+ * Props optionnelles :
9
+ * width number (défaut 480) - largeur du viewBox en px
10
+ * height number (défaut 260) - hauteur du viewBox en px
11
+ * class string - classe CSS supplémentaire
12
+ *
13
+ * NaN/vide : les valeurs non-finies sont exclues du domaine (filter Number.isFinite).
14
+ * Tableau vide → rendu vide sans crash.
15
+ * Note : l'ordre min≤q1≤median≤q3≤max n'est pas imposé par clamp mais rendu
16
+ * tel quel (abs() sur la hauteur de boîte) ; les données incohérentes produisent
17
+ * un rendu plausible mais peuvent induire en erreur.
18
+ */
19
+ export type BoxPlotChartTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
20
+ export type BoxPlotChartDatum = {
21
+ label: string;
22
+ min: number;
23
+ q1: number;
24
+ median: number;
25
+ q3: number;
26
+ max: number;
27
+ outliers?: number[];
28
+ tone?: BoxPlotChartTone;
29
+ };
30
+ type BoxPlotChartProps = {
31
+ data: BoxPlotChartDatum[];
32
+ label: string;
33
+ width?: number;
34
+ height?: number;
35
+ class?: string;
36
+ };
37
+ declare const BoxPlotChart: import("svelte").Component<BoxPlotChartProps, {}, "">;
38
+ type BoxPlotChart = ReturnType<typeof BoxPlotChart>;
39
+ export default BoxPlotChart;
40
+ //# sourceMappingURL=BoxPlotChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BoxPlotChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BoxPlotChart.svelte.ts"],"names":[],"mappings":"AAGE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,gBAAgB,GACxB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAMF,KAAK,iBAAiB,GAAG;IACvB,IAAI,EAAE,iBAAiB,EAAE,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiJJ,QAAA,MAAM,YAAY,uDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -173,22 +173,56 @@
173
173
  return d > rangeStart.getTime() && d < rangeEnd.getTime();
174
174
  }
175
175
 
176
- function previousMonth() {
177
- if (viewMonth === 0) {
178
- viewMonth = 11;
179
- viewYear -= 1;
180
- } else {
181
- viewMonth -= 1;
176
+ /**
177
+ * Retourne le premier jour activable (non-disabled) du mois `year`/`month`,
178
+ * en partant de `preferred` si celui-ci est dans le bon mois et non-disabled,
179
+ * sinon en balayant du 1er au dernier jour du mois.
180
+ * Renvoie `null` si tous les jours sont disabled (cas extrême).
181
+ */
182
+ function clampToMonth(preferred: Date, year: number, month: number): Date | null {
183
+ // Si preferred est dans le bon mois et non-disabled → on le garde.
184
+ if (
185
+ preferred.getFullYear() === year &&
186
+ preferred.getMonth() === month &&
187
+ !isOutOfBounds(preferred)
188
+ ) {
189
+ return preferred;
190
+ }
191
+ // Chercher le jour sélectionné dans ce mois en priorité.
192
+ const sel = !range ? single : rangeStart;
193
+ if (sel && sel.getFullYear() === year && sel.getMonth() === month && !isOutOfBounds(sel)) {
194
+ return sel;
195
+ }
196
+ // Balayer du 1er au dernier jour du mois.
197
+ const lastDay = new Date(year, month + 1, 0).getDate();
198
+ for (let d = 1; d <= lastDay; d++) {
199
+ const candidate = startOfDay(new Date(year, month, d));
200
+ if (!isOutOfBounds(candidate)) return candidate;
182
201
  }
202
+ // Aucun jour activable (mois entièrement hors-bornes) : retourner null pour
203
+ // signaler l'absence de cellule focusable. Les appelants doivent traiter ce cas.
204
+ return null;
205
+ }
206
+
207
+ function previousMonth() {
208
+ const targetMonth = viewMonth === 0 ? 11 : viewMonth - 1;
209
+ const targetYear = viewMonth === 0 ? viewYear - 1 : viewYear;
210
+ viewMonth = targetMonth;
211
+ viewYear = targetYear;
212
+ const clamped = clampToMonth(focusDate, targetYear, targetMonth);
213
+ if (clamped) focusDate = clamped;
214
+ // Si clamped === null, le mois est entièrement hors-bornes : focusDate garde
215
+ // l'ancienne valeur — aucune cellule ne sera tabindex=0 car toutes sont disabled.
183
216
  }
184
217
 
185
218
  function nextMonth() {
186
- if (viewMonth === 11) {
187
- viewMonth = 0;
188
- viewYear += 1;
189
- } else {
190
- viewMonth += 1;
191
- }
219
+ const targetMonth = viewMonth === 11 ? 0 : viewMonth + 1;
220
+ const targetYear = viewMonth === 11 ? viewYear + 1 : viewYear;
221
+ viewMonth = targetMonth;
222
+ viewYear = targetYear;
223
+ const clamped = clampToMonth(focusDate, targetYear, targetMonth);
224
+ if (clamped) focusDate = clamped;
225
+ // Si clamped === null, le mois est entièrement hors-bornes : idem.
192
226
  }
193
227
 
194
228
  function pickDate(date: Date) {
@@ -217,13 +251,149 @@
217
251
 
218
252
  const monthLabel = $derived(monthFormatter.format(new Date(viewYear, viewMonth, 1)));
219
253
 
254
+ // --- Roving tabindex : date active dans la grille -------------------------
255
+ // La "date active" est celle qui a tabindex=0 ; elle suit la sélection ou
256
+ // se positionne sur le 1er jour activable du mois affiché en l'absence de sélection.
257
+ // INVARIANT : focusDate est toujours dans le mois affiché ET non-disabled.
258
+ function initialFocusDate(): Date {
259
+ const sel = !range ? single : rangeStart;
260
+ if (sel && sel.getFullYear() === viewYear && sel.getMonth() === viewMonth && !isOutOfBounds(sel)) {
261
+ return sel;
262
+ }
263
+ // Trouver le premier jour activable du mois.
264
+ const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
265
+ for (let d = 1; d <= lastDay; d++) {
266
+ const candidate = startOfDay(new Date(viewYear, viewMonth, d));
267
+ if (!isOutOfBounds(candidate)) return candidate;
268
+ }
269
+ // Mois entièrement hors-bornes : retourner le 1er jour quand même pour
270
+ // initialiser focusDate, mais aucune cellule ne sera tabindex=0 (toutes disabled).
271
+ return startOfDay(new Date(viewYear, viewMonth, 1));
272
+ }
273
+
274
+ let focusDate = $state<Date>(initialFocusDate());
275
+
276
+ // Resynchronise focusDate quand la prop value change (sélection externe).
277
+ // Si la nouvelle valeur est dans le mois affiché et non-disabled, on la pointe ;
278
+ // sinon on re-clamp pour garantir l'invariant.
279
+ $effect(() => {
280
+ const sel = !range ? single : rangeStart;
281
+ if (sel) {
282
+ if (sel.getFullYear() === viewYear && sel.getMonth() === viewMonth && !isOutOfBounds(sel)) {
283
+ focusDate = sel;
284
+ } else {
285
+ const clamped = clampToMonth(focusDate, viewYear, viewMonth);
286
+ if (clamped) focusDate = clamped;
287
+ // Si null (mois entièrement hors-bornes), on ne touche pas focusDate :
288
+ // le tabindex=0 ne sera posé sur aucune cellule disabled.
289
+ }
290
+ }
291
+ });
292
+
293
+ // Resynchronise focusDate quand le mois affiché change via la prop `month`
294
+ // (l'$effect sur `month` dans le bloc précédent met viewYear/viewMonth à jour,
295
+ // mais focusDate peut pointer vers l'ancien mois).
296
+ $effect(() => {
297
+ // Dépendances explicites : viewYear + viewMonth.
298
+ const y = viewYear;
299
+ const m = viewMonth;
300
+ if (focusDate.getFullYear() !== y || focusDate.getMonth() !== m || isOutOfBounds(focusDate)) {
301
+ const clamped = clampToMonth(focusDate, y, m);
302
+ if (clamped) focusDate = clamped;
303
+ // Si null (mois entièrement hors-bornes), aucune cellule ne reçoit tabindex=0.
304
+ }
305
+ });
306
+
307
+ // Résoud l'élément DOM du jour actif et y place le focus.
308
+ let gridEl = $state<HTMLElement | null>(null);
309
+
310
+ function focusActiveCell() {
311
+ if (!gridEl) return;
312
+ const iso = toISO(focusDate);
313
+ const btn = gridEl.querySelector<HTMLElement>(`[data-date="${iso}"]`);
314
+ btn?.focus();
315
+ }
316
+
317
+ // Déplace focusDate de `deltaDays` jours ; change de mois si nécessaire.
318
+ function moveFocus(deltaDays: number) {
319
+ const next = new Date(focusDate);
320
+ next.setDate(next.getDate() + deltaDays);
321
+ // Si hors mois affiché, on bascule le mois.
322
+ if (next.getFullYear() !== viewYear || next.getMonth() !== viewMonth) {
323
+ viewYear = next.getFullYear();
324
+ viewMonth = next.getMonth();
325
+ }
326
+ focusDate = startOfDay(next);
327
+ // Focus après rendu.
328
+ setTimeout(focusActiveCell, 0);
329
+ }
330
+
220
331
  function onKeyDown(event: KeyboardEvent) {
221
- if (event.key === "PageUp") {
222
- event.preventDefault();
223
- previousMonth();
224
- } else if (event.key === "PageDown") {
225
- event.preventDefault();
226
- nextMonth();
332
+ switch (event.key) {
333
+ case "ArrowLeft":
334
+ event.preventDefault();
335
+ moveFocus(-1);
336
+ break;
337
+ case "ArrowRight":
338
+ event.preventDefault();
339
+ moveFocus(1);
340
+ break;
341
+ case "ArrowUp":
342
+ event.preventDefault();
343
+ moveFocus(-7);
344
+ break;
345
+ case "ArrowDown":
346
+ event.preventDefault();
347
+ moveFocus(7);
348
+ break;
349
+ case "Home": {
350
+ // Début de la semaine (selon weekStartsOn).
351
+ event.preventDefault();
352
+ const dayOfWeek = focusDate.getDay();
353
+ const offset = (dayOfWeek - weekStartsOn + 7) % 7;
354
+ moveFocus(-offset);
355
+ break;
356
+ }
357
+ case "End": {
358
+ // Fin de la semaine.
359
+ event.preventDefault();
360
+ const dayOfWeek = focusDate.getDay();
361
+ const offset = (6 - ((dayOfWeek - weekStartsOn + 7) % 7));
362
+ moveFocus(offset);
363
+ break;
364
+ }
365
+ case "PageUp": {
366
+ event.preventDefault();
367
+ // previousMonth() met à jour viewYear/viewMonth ET clamp focusDate via clampToMonth,
368
+ // en essayant de conserver le même numéro de jour dans le mois cible.
369
+ const puDay = focusDate.getDate();
370
+ const puTargetMonth = viewMonth === 0 ? 11 : viewMonth - 1;
371
+ const puTargetYear = viewMonth === 0 ? viewYear - 1 : viewYear;
372
+ // Construit le candidat "même jour" avant d'appeler previousMonth pour que
373
+ // clampToMonth puisse l'évaluer (il utilise focusDate en argument).
374
+ const puLastDay = new Date(puTargetYear, puTargetMonth + 1, 0).getDate();
375
+ focusDate = startOfDay(new Date(puTargetYear, puTargetMonth, Math.min(puDay, puLastDay)));
376
+ previousMonth();
377
+ setTimeout(focusActiveCell, 0);
378
+ break;
379
+ }
380
+ case "PageDown": {
381
+ event.preventDefault();
382
+ const pdDay = focusDate.getDate();
383
+ const pdTargetMonth = viewMonth === 11 ? 0 : viewMonth + 1;
384
+ const pdTargetYear = viewMonth === 11 ? viewYear + 1 : viewYear;
385
+ const pdLastDay = new Date(pdTargetYear, pdTargetMonth + 1, 0).getDate();
386
+ focusDate = startOfDay(new Date(pdTargetYear, pdTargetMonth, Math.min(pdDay, pdLastDay)));
387
+ nextMonth();
388
+ setTimeout(focusActiveCell, 0);
389
+ break;
390
+ }
391
+ case "Enter":
392
+ case " ": {
393
+ event.preventDefault();
394
+ if (!isOutOfBounds(focusDate)) pickDate(focusDate);
395
+ break;
396
+ }
227
397
  }
228
398
  }
229
399
  </script>
@@ -248,12 +418,15 @@
248
418
  <ChevronRight size={18} aria-hidden="true" />
249
419
  </button>
250
420
  </div>
421
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
422
+ <!-- Faux positif : le grid utilise le roving tabindex (cellules-enfants portent tabindex),
423
+ pas un tabindex sur le conteneur — conforme ARIA Grid Pattern. -->
251
424
  <div
252
425
  class="st-calendar__grid"
253
426
  role="grid"
254
- tabindex="-1"
255
427
  aria-label={monthLabel}
256
428
  onkeydown={onKeyDown}
429
+ bind:this={gridEl}
257
430
  >
258
431
  <div class="st-calendar__weekdays" role="row">
259
432
  {#each weekdayLabels as wd (wd)}
@@ -261,28 +434,38 @@
261
434
  {/each}
262
435
  </div>
263
436
  <div class="st-calendar__days">
264
- {#each grid as cell, i (i)}
265
- {@const oob = isOutOfBounds(cell.date)}
266
- {@const selected = isSelected(cell.date)}
267
- {@const inRange = isInRange(cell.date)}
268
- {@const isToday = isSameDay(cell.date, today)}
269
- <button
270
- type="button"
271
- class="st-calendar__day"
272
- class:st-calendar__day--outside={!cell.inMonth}
273
- class:st-calendar__day--selected={selected}
274
- class:st-calendar__day--inRange={inRange}
275
- class:st-calendar__day--today={isToday}
276
- role="gridcell"
277
- aria-label={cellFormatter.format(cell.date)}
278
- aria-selected={selected ? "true" : "false"}
279
- aria-current={isToday ? "date" : undefined}
280
- aria-disabled={oob ? "true" : undefined}
281
- disabled={oob}
282
- onclick={() => pickDate(cell.date)}
283
- >
284
- {cell.date.getDate()}
285
- </button>
437
+ {#each { length: 6 } as _, rowIdx (rowIdx)}
438
+ <div class="st-calendar__week" role="row">
439
+ {#each grid.slice(rowIdx * 7, rowIdx * 7 + 7) as cell, colIdx (rowIdx * 7 + colIdx)}
440
+ {@const oob = isOutOfBounds(cell.date)}
441
+ {@const selected = isSelected(cell.date)}
442
+ {@const inRange = isInRange(cell.date)}
443
+ {@const isToday = isSameDay(cell.date, today)}
444
+ {@const isActive = isSameDay(cell.date, focusDate)}
445
+ <button
446
+ type="button"
447
+ class="st-calendar__day"
448
+ class:st-calendar__day--outside={!cell.inMonth}
449
+ class:st-calendar__day--selected={selected}
450
+ class:st-calendar__day--inRange={inRange}
451
+ class:st-calendar__day--today={isToday}
452
+ role="gridcell"
453
+ aria-label={cellFormatter.format(cell.date)}
454
+ aria-selected={selected ? "true" : "false"}
455
+ aria-current={isToday ? "date" : undefined}
456
+ aria-disabled={oob ? "true" : undefined}
457
+ disabled={oob}
458
+ tabindex={isActive && !oob ? 0 : -1}
459
+ data-date={toISO(cell.date)}
460
+ onclick={() => {
461
+ focusDate = startOfDay(cell.date);
462
+ pickDate(cell.date);
463
+ }}
464
+ >
465
+ {cell.date.getDate()}
466
+ </button>
467
+ {/each}
468
+ </div>
286
469
  {/each}
287
470
  </div>
288
471
  </div>
@@ -340,10 +523,22 @@
340
523
  gap: var(--st-spacing-1, 0.25rem);
341
524
  }
342
525
 
343
- .st-calendar__weekdays,
526
+ .st-calendar__weekdays {
527
+ display: grid;
528
+ gap: 2px;
529
+ grid-template-columns: repeat(7, minmax(2rem, 1fr));
530
+ }
531
+
344
532
  .st-calendar__days {
345
533
  display: grid;
346
534
  gap: 2px;
535
+ }
536
+
537
+ /* role="row" doit être un vrai nœud exposé à l'arbre a11y.
538
+ display:contents supprime le nœud → on utilise display:grid à la place. */
539
+ .st-calendar__week {
540
+ display: grid;
541
+ gap: 2px;
347
542
  grid-template-columns: repeat(7, minmax(2rem, 1fr));
348
543
  }
349
544
 
@@ -1 +1 @@
1
- {"version":3,"file":"Calendar.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Calendar.svelte.ts"],"names":[],"mappings":"AAGE;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;AAI7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,aAAa,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAChF,wEAAwE;IACxE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC1C,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AA+OJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Calendar.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Calendar.svelte.ts"],"names":[],"mappings":"AAGE;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;AAI7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,aAAa,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,GAAG;IAChF,wEAAwE;IACxE,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC1C,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAmaJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}