@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.
- package/dist/BoxPlotChart.svelte +302 -0
- package/dist/BoxPlotChart.svelte.d.ts +40 -0
- package/dist/BoxPlotChart.svelte.d.ts.map +1 -0
- package/dist/Calendar.svelte +237 -42
- package/dist/Calendar.svelte.d.ts.map +1 -1
- package/dist/HeatmapChart.svelte +337 -0
- package/dist/HeatmapChart.svelte.d.ts +35 -0
- package/dist/HeatmapChart.svelte.d.ts.map +1 -0
- package/dist/HistogramChart.svelte +294 -0
- package/dist/HistogramChart.svelte.d.ts +38 -0
- package/dist/HistogramChart.svelte.d.ts.map +1 -0
- 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/RadarChart.svelte +340 -0
- package/dist/RadarChart.svelte.d.ts +43 -0
- package/dist/RadarChart.svelte.d.ts.map +1 -0
- package/dist/Rating.svelte +130 -35
- package/dist/Rating.svelte.d.ts.map +1 -1
- package/dist/SankeyChart.svelte +364 -0
- package/dist/SankeyChart.svelte.d.ts +45 -0
- package/dist/SankeyChart.svelte.d.ts.map +1 -0
- 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/SunburstChart.svelte +388 -0
- package/dist/SunburstChart.svelte.d.ts +39 -0
- package/dist/SunburstChart.svelte.d.ts.map +1 -0
- package/dist/TimePicker.svelte +176 -13
- package/dist/TimePicker.svelte.d.ts.map +1 -1
- package/dist/chartContrast.d.ts +0 -4
- package/dist/chartContrast.d.ts.map +1 -1
- package/dist/chartContrast.js +4 -56
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- 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"}
|
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"}
|