@sentropic/design-system-svelte 0.15.0 → 0.17.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.
@@ -0,0 +1,36 @@
1
+ export type GaugeChartTone = "neutral" | "info" | "success" | "warning" | "error" | "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
2
+ /**
3
+ * Seuil de coloration. La bande s'étend depuis `value` (ou le minimum)
4
+ * jusqu'au seuil suivant (ou le maximum). `tone` choisit la couleur.
5
+ */
6
+ export type GaugeChartThreshold = {
7
+ value: number;
8
+ tone: GaugeChartTone;
9
+ };
10
+ export type GaugeChartFormat = "number" | "percent";
11
+ type GaugeChartProps = {
12
+ value: number;
13
+ min?: number;
14
+ max?: number;
15
+ /** Bandes colorées sur la piste. Triées par `value` croissante. */
16
+ thresholds?: GaugeChartThreshold[];
17
+ /** Libellé décrivant la jauge (a11y + texte sous la valeur). */
18
+ label?: string;
19
+ /** Format de la valeur centrale. */
20
+ format?: GaugeChartFormat;
21
+ /** Suffixe d'unité (ignoré pour `percent`). */
22
+ unit?: string;
23
+ /** Diamètre du SVG. */
24
+ size?: number;
25
+ /** Épaisseur de l'arc. */
26
+ thickness?: number;
27
+ /** Angle de départ en degrés (0 = est, sens horaire). */
28
+ startAngle?: number;
29
+ /** Angle de fin en degrés. */
30
+ endAngle?: number;
31
+ class?: string;
32
+ };
33
+ declare const GaugeChart: import("svelte").Component<GaugeChartProps, {}, "">;
34
+ type GaugeChart = ReturnType<typeof GaugeChart>;
35
+ export default GaugeChart;
36
+ //# sourceMappingURL=GaugeChart.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GaugeChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/GaugeChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GACpD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,cAAc,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,SAAS,CAAC;AAMpD,KAAK,eAAe,GAAG;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,UAAU,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACnC,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA2JJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,318 @@
1
+ <script lang="ts" module>
2
+ export type KpiCardSize = "sm" | "md" | "lg";
3
+ export type KpiCardTrend = "up" | "down" | "flat";
4
+ export type KpiCardFormat = "number" | "currency" | "percent";
5
+ export type KpiCardDeltaFormat = "percent" | "absolute";
6
+ export type KpiCardTone =
7
+ | "category1"
8
+ | "category2"
9
+ | "category3"
10
+ | "category4"
11
+ | "category5"
12
+ | "category6"
13
+ | "category7"
14
+ | "category8";
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import type { HTMLAttributes } from "svelte/elements";
19
+ import Sparkline from "./Sparkline.svelte";
20
+
21
+ type KpiCardProps = Omit<HTMLAttributes<HTMLElement>, "class"> & {
22
+ /** Valeur principale affichée en grand. */
23
+ value: number | string;
24
+ /** Étiquette de l'indicateur (ex. « Revenu mensuel »). */
25
+ label: string;
26
+ /** Variation par rapport à la période précédente. */
27
+ delta?: number;
28
+ /** Comment exprimer le delta : en pourcentage ou en valeur absolue. */
29
+ deltaFormat?: KpiCardDeltaFormat;
30
+ /** Tendance ; déduite du signe du delta si absente. */
31
+ trend?: KpiCardTrend;
32
+ /** Formatage de la valeur principale via Intl.NumberFormat. */
33
+ format?: KpiCardFormat;
34
+ /** Unité suffixée à la valeur (ex. « ms », « €/mois »). */
35
+ unit?: string;
36
+ /** Code devise ISO 4217 pour format="currency" (défaut EUR). */
37
+ currency?: string;
38
+ /** Locale BCP 47 pour le formatage des nombres (défaut undefined = locale du runtime). */
39
+ locale?: string;
40
+ /** Mini-graphique de tendance optionnel. */
41
+ sparkline?: number[];
42
+ size?: KpiCardSize;
43
+ /** Couleur catégorielle pour l'accent (bordure de gauche). */
44
+ tone?: KpiCardTone;
45
+ class?: string;
46
+ };
47
+
48
+ let {
49
+ value,
50
+ label,
51
+ delta,
52
+ deltaFormat = "percent",
53
+ trend,
54
+ format = "number",
55
+ unit,
56
+ currency = "EUR",
57
+ locale,
58
+ sparkline,
59
+ size = "md",
60
+ tone,
61
+ class: className,
62
+ ...rest
63
+ }: KpiCardProps = $props();
64
+
65
+ const resolvedTrend = $derived<KpiCardTrend | undefined>(
66
+ trend ?? (delta == null ? undefined : delta > 0 ? "up" : delta < 0 ? "down" : "flat")
67
+ );
68
+
69
+ const formattedValue = $derived.by(() => {
70
+ if (typeof value === "string") {
71
+ return value;
72
+ }
73
+ if (format === "currency") {
74
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
75
+ }
76
+ if (format === "percent") {
77
+ return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 2 }).format(
78
+ value
79
+ );
80
+ }
81
+ return new Intl.NumberFormat(locale).format(value);
82
+ });
83
+
84
+ const formattedDelta = $derived.by(() => {
85
+ if (delta == null) {
86
+ return undefined;
87
+ }
88
+ const sign = delta > 0 ? "+" : "";
89
+ if (deltaFormat === "percent") {
90
+ const pct = new Intl.NumberFormat(locale, {
91
+ style: "percent",
92
+ maximumFractionDigits: 1
93
+ }).format(delta);
94
+ return `${sign}${pct}`;
95
+ }
96
+ return `${sign}${new Intl.NumberFormat(locale).format(delta)}`;
97
+ });
98
+
99
+ /** Le sparkline emprunte la couleur sémantique de la tendance. */
100
+ const sparklineTone = $derived(
101
+ resolvedTrend === "up" ? "success" : resolvedTrend === "down" ? "error" : "neutral"
102
+ );
103
+
104
+ const arrow = $derived(
105
+ resolvedTrend === "up" ? "M3 8.5 7 4l4 4.5" : resolvedTrend === "down" ? "M3 5.5 7 10l4-4.5" : "M3 7h8"
106
+ );
107
+
108
+ const trendLabel = $derived(
109
+ resolvedTrend === "up"
110
+ ? "en hausse"
111
+ : resolvedTrend === "down"
112
+ ? "en baisse"
113
+ : resolvedTrend === "flat"
114
+ ? "stable"
115
+ : undefined
116
+ );
117
+
118
+ const ariaLabel = $derived(
119
+ [label, formattedValue, unit, formattedDelta && `${formattedDelta} ${trendLabel ?? ""}`.trim()]
120
+ .filter(Boolean)
121
+ .join(", ")
122
+ );
123
+
124
+ const classes = $derived(
125
+ [
126
+ "st-kpiCard",
127
+ `st-kpiCard--${size}`,
128
+ tone && `st-kpiCard--${tone}`,
129
+ tone && "st-kpiCard--toned",
130
+ className
131
+ ]
132
+ .filter(Boolean)
133
+ .join(" ")
134
+ );
135
+ </script>
136
+
137
+ <article {...rest} class={classes} role="group" aria-label={ariaLabel}>
138
+ <p class="st-kpiCard__label">{label}</p>
139
+
140
+ <p class="st-kpiCard__value">
141
+ <span class="st-kpiCard__number">{formattedValue}</span>
142
+ {#if unit}
143
+ <span class="st-kpiCard__unit">{unit}</span>
144
+ {/if}
145
+ </p>
146
+
147
+ {#if formattedDelta || sparkline}
148
+ <div class="st-kpiCard__footer">
149
+ {#if formattedDelta}
150
+ <span
151
+ class="st-kpiCard__delta st-kpiCard__delta--{resolvedTrend ?? 'flat'}"
152
+ aria-hidden="true"
153
+ >
154
+ <svg
155
+ class="st-kpiCard__arrow"
156
+ width="14"
157
+ height="14"
158
+ viewBox="0 0 14 14"
159
+ aria-hidden="true"
160
+ focusable="false"
161
+ >
162
+ <path
163
+ d={arrow}
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="1.75"
167
+ stroke-linecap="round"
168
+ stroke-linejoin="round"
169
+ />
170
+ </svg>
171
+ <span class="st-kpiCard__deltaValue">{formattedDelta}</span>
172
+ </span>
173
+ {/if}
174
+
175
+ {#if sparkline && sparkline.length > 0}
176
+ <Sparkline
177
+ class="st-kpiCard__sparkline"
178
+ data={sparkline}
179
+ tone={sparklineTone}
180
+ area
181
+ />
182
+ {/if}
183
+ </div>
184
+ {/if}
185
+ </article>
186
+
187
+ <style>
188
+ .st-kpiCard {
189
+ background: var(--st-component-card-background, var(--st-semantic-surface-raised));
190
+ border-width: var(--st-component-card-anatomy-shape-borderWidth, 1px);
191
+ border-style: var(--st-component-card-anatomy-shape-borderStyle, solid);
192
+ border-color: var(--st-component-card-border, var(--st-semantic-border-subtle));
193
+ border-radius: var(--st-component-card-anatomy-shape-radius, 0.5rem);
194
+ box-shadow: var(--st-component-card-shadow, 0 1px 2px rgb(15 23 42 / 0.08));
195
+ color: var(--st-semantic-text-primary);
196
+ display: flex;
197
+ flex-direction: column;
198
+ gap: var(--st-spacing-2, 0.5rem);
199
+ padding: var(--st-spacing-4, 1rem);
200
+ }
201
+
202
+ .st-kpiCard--sm {
203
+ gap: var(--st-spacing-1, 0.25rem);
204
+ padding: var(--st-spacing-3, 0.75rem);
205
+ }
206
+
207
+ .st-kpiCard--lg {
208
+ gap: var(--st-spacing-3, 0.75rem);
209
+ padding: var(--st-spacing-6, 1.5rem);
210
+ }
211
+
212
+ /* Accent catégoriel : liseré coloré à gauche. */
213
+ .st-kpiCard--toned {
214
+ border-inline-start-width: var(--st-spacing-1, 0.25rem);
215
+ }
216
+
217
+ .st-kpiCard__label {
218
+ color: var(--st-semantic-text-secondary);
219
+ font-size: 0.8125rem;
220
+ font-weight: 500;
221
+ line-height: 1.25;
222
+ margin: 0;
223
+ }
224
+
225
+ .st-kpiCard--lg .st-kpiCard__label {
226
+ font-size: 0.875rem;
227
+ }
228
+
229
+ .st-kpiCard__value {
230
+ align-items: baseline;
231
+ color: var(--st-semantic-text-primary);
232
+ display: flex;
233
+ flex-wrap: wrap;
234
+ gap: var(--st-spacing-1, 0.25rem);
235
+ margin: 0;
236
+ }
237
+
238
+ .st-kpiCard__number {
239
+ font-size: 1.5rem;
240
+ font-weight: 700;
241
+ letter-spacing: -0.01em;
242
+ line-height: 1.1;
243
+ }
244
+
245
+ .st-kpiCard--sm .st-kpiCard__number {
246
+ font-size: 1.25rem;
247
+ }
248
+
249
+ .st-kpiCard--lg .st-kpiCard__number {
250
+ font-size: 2.25rem;
251
+ }
252
+
253
+ .st-kpiCard__unit {
254
+ color: var(--st-semantic-text-secondary);
255
+ font-size: 0.875rem;
256
+ font-weight: 500;
257
+ }
258
+
259
+ .st-kpiCard__footer {
260
+ align-items: center;
261
+ display: flex;
262
+ flex-wrap: wrap;
263
+ gap: var(--st-spacing-3, 0.75rem);
264
+ justify-content: space-between;
265
+ }
266
+
267
+ .st-kpiCard__delta {
268
+ align-items: center;
269
+ display: inline-flex;
270
+ font-size: 0.8125rem;
271
+ font-weight: 600;
272
+ gap: var(--st-spacing-1, 0.25rem);
273
+ line-height: 1;
274
+ }
275
+
276
+ .st-kpiCard__arrow {
277
+ display: block;
278
+ flex: 0 0 auto;
279
+ }
280
+
281
+ .st-kpiCard__delta--up {
282
+ color: var(--st-semantic-feedback-success);
283
+ }
284
+
285
+ .st-kpiCard__delta--down {
286
+ color: var(--st-semantic-feedback-error);
287
+ }
288
+
289
+ .st-kpiCard__delta--flat {
290
+ color: var(--st-semantic-text-secondary);
291
+ }
292
+
293
+ /* Liserés catégoriels — alignés sur la palette data des autres composants. */
294
+ .st-kpiCard--category1 {
295
+ border-inline-start-color: var(--st-semantic-data-category1);
296
+ }
297
+ .st-kpiCard--category2 {
298
+ border-inline-start-color: var(--st-semantic-data-category2);
299
+ }
300
+ .st-kpiCard--category3 {
301
+ border-inline-start-color: var(--st-semantic-data-category3);
302
+ }
303
+ .st-kpiCard--category4 {
304
+ border-inline-start-color: var(--st-semantic-data-category4);
305
+ }
306
+ .st-kpiCard--category5 {
307
+ border-inline-start-color: var(--st-semantic-data-category5);
308
+ }
309
+ .st-kpiCard--category6 {
310
+ border-inline-start-color: var(--st-semantic-data-category6);
311
+ }
312
+ .st-kpiCard--category7 {
313
+ border-inline-start-color: var(--st-semantic-data-category7);
314
+ }
315
+ .st-kpiCard--category8 {
316
+ border-inline-start-color: var(--st-semantic-data-category8);
317
+ }
318
+ </style>
@@ -0,0 +1,36 @@
1
+ export type KpiCardSize = "sm" | "md" | "lg";
2
+ export type KpiCardTrend = "up" | "down" | "flat";
3
+ export type KpiCardFormat = "number" | "currency" | "percent";
4
+ export type KpiCardDeltaFormat = "percent" | "absolute";
5
+ export type KpiCardTone = "category1" | "category2" | "category3" | "category4" | "category5" | "category6" | "category7" | "category8";
6
+ import type { HTMLAttributes } from "svelte/elements";
7
+ type KpiCardProps = Omit<HTMLAttributes<HTMLElement>, "class"> & {
8
+ /** Valeur principale affichée en grand. */
9
+ value: number | string;
10
+ /** Étiquette de l'indicateur (ex. « Revenu mensuel »). */
11
+ label: string;
12
+ /** Variation par rapport à la période précédente. */
13
+ delta?: number;
14
+ /** Comment exprimer le delta : en pourcentage ou en valeur absolue. */
15
+ deltaFormat?: KpiCardDeltaFormat;
16
+ /** Tendance ; déduite du signe du delta si absente. */
17
+ trend?: KpiCardTrend;
18
+ /** Formatage de la valeur principale via Intl.NumberFormat. */
19
+ format?: KpiCardFormat;
20
+ /** Unité suffixée à la valeur (ex. « ms », « €/mois »). */
21
+ unit?: string;
22
+ /** Code devise ISO 4217 pour format="currency" (défaut EUR). */
23
+ currency?: string;
24
+ /** Locale BCP 47 pour le formatage des nombres (défaut undefined = locale du runtime). */
25
+ locale?: string;
26
+ /** Mini-graphique de tendance optionnel. */
27
+ sparkline?: number[];
28
+ size?: KpiCardSize;
29
+ /** Couleur catégorielle pour l'accent (bordure de gauche). */
30
+ tone?: KpiCardTone;
31
+ class?: string;
32
+ };
33
+ declare const KpiCard: import("svelte").Component<KpiCardProps, {}, "">;
34
+ type KpiCard = ReturnType<typeof KpiCard>;
35
+ export default KpiCard;
36
+ //# sourceMappingURL=KpiCard.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"KpiCard.svelte.d.ts","sourceRoot":"","sources":["../src/lib/KpiCard.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAC7C,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAClD,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAC9D,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,UAAU,CAAC;AACxD,MAAM,MAAM,WAAW,GACnB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAGlB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIpD,KAAK,YAAY,GAAG,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,GAAG;IAC/D,2CAA2C;IAC3C,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,uDAAuD;IACvD,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiIJ,QAAA,MAAM,OAAO,kDAAwC,CAAC;AACtD,KAAK,OAAO,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;AAC1C,eAAe,OAAO,CAAC"}
@@ -0,0 +1,186 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+
4
+ export type SelectableListProps = {
5
+ /** Accessible name for the listbox (required for SR users). */
6
+ label?: string;
7
+ /** References the id of an external visible label (alternative to `label`). */
8
+ labelledby?: string;
9
+ /**
10
+ * Allow more than one selected row. Adds aria-multiselectable and toggles
11
+ * each row independently. Defaults to false (single-select).
12
+ */
13
+ multiple?: boolean;
14
+ /**
15
+ * Selected value(s). Controlled when provided. For single-select pass a
16
+ * string (or null); for multiple pass a string[]. When omitted the list is
17
+ * uncontrolled and keeps its own internal selection.
18
+ */
19
+ value?: string | string[] | null;
20
+ /**
21
+ * Fired with the new selection on every change. Receives a string|null for
22
+ * single-select and a string[] for multiple. Required for the controlled
23
+ * pattern; also fires for uncontrolled lists.
24
+ */
25
+ onchange?: (value: string | string[] | null) => void;
26
+ class?: string;
27
+ children?: Snippet;
28
+ };
29
+ </script>
30
+
31
+ <script lang="ts">
32
+ import { setContext, untrack } from "svelte";
33
+ import {
34
+ SELECTABLE_LIST_KEY,
35
+ type SelectableListContext
36
+ } from "./SelectableRow.svelte";
37
+
38
+ let {
39
+ label,
40
+ labelledby,
41
+ multiple = false,
42
+ value,
43
+ onchange,
44
+ class: className,
45
+ children
46
+ }: SelectableListProps = $props();
47
+
48
+ // Controlled when the consumer passes `value` (including null). Otherwise the
49
+ // list owns an internal selection set.
50
+ const controlled = $derived(value !== undefined);
51
+
52
+ function toSet(v: string | string[] | null | undefined): Set<string> {
53
+ if (v == null) return new Set();
54
+ return new Set(Array.isArray(v) ? v : [v]);
55
+ }
56
+
57
+ // Internal selection for the uncontrolled case.
58
+ let internal = $state<Set<string>>(new Set());
59
+ const selectedValues = $derived(controlled ? toSet(value) : internal);
60
+
61
+ // --- Row registry: ordered by DOM position so arrow nav matches the visual
62
+ // order regardless of registration timing. -------------------------------
63
+ type Entry = { el: HTMLElement; value: string | undefined };
64
+ let entries = $state<Entry[]>([]);
65
+
66
+ // The element that currently holds the roving tab stop (tabindex 0). Null until
67
+ // a row is focused; until then the FIRST enabled row is the default stop.
68
+ let tabStopEl = $state<HTMLElement | null>(null);
69
+
70
+ function sortByDom(list: Entry[]): Entry[] {
71
+ return [...list].sort((a, b) => {
72
+ const pos = a.el.compareDocumentPosition(b.el);
73
+ if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
74
+ if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
75
+ return 0;
76
+ });
77
+ }
78
+
79
+ // register/unregister are called from each row's $effect. They read AND write
80
+ // `entries`, so the read must be untracked — otherwise the calling effect would
81
+ // subscribe to `entries`, and writing it would re-run the effect forever.
82
+ function register(el: HTMLElement, rowValue: string | undefined): () => void {
83
+ untrack(() => {
84
+ entries = sortByDom([...entries.filter((e) => e.el !== el), { el, value: rowValue }]);
85
+ });
86
+ return () => {
87
+ untrack(() => {
88
+ entries = entries.filter((e) => e.el !== el);
89
+ if (tabStopEl === el) tabStopEl = null;
90
+ });
91
+ };
92
+ }
93
+
94
+ // Default roving stop = first registered (DOM-ordered) row when none focused.
95
+ const effectiveTabStop = $derived(tabStopEl ?? entries[0]?.el ?? null);
96
+
97
+ function valueOf(el: HTMLElement): string | undefined {
98
+ return entries.find((e) => e.el === el)?.value;
99
+ }
100
+
101
+ function isSelected(el: HTMLElement): boolean {
102
+ const v = valueOf(el);
103
+ return v !== undefined && selectedValues.has(v);
104
+ }
105
+
106
+ function isTabStop(el: HTMLElement): boolean {
107
+ return el === effectiveTabStop;
108
+ }
109
+
110
+ function emit(next: Set<string>) {
111
+ if (!controlled) internal = next;
112
+ if (multiple) onchange?.([...next]);
113
+ else onchange?.(next.size ? [...next][0] : null);
114
+ }
115
+
116
+ function activate(el: HTMLElement) {
117
+ const v = valueOf(el);
118
+ if (v === undefined) return;
119
+ const current = selectedValues;
120
+ let next: Set<string>;
121
+ if (multiple) {
122
+ next = new Set(current);
123
+ if (next.has(v)) next.delete(v);
124
+ else next.add(v);
125
+ } else {
126
+ // Single-select toggles off when re-activating the selected row.
127
+ next = current.has(v) && current.size === 1 ? new Set() : new Set([v]);
128
+ }
129
+ emit(next);
130
+ }
131
+
132
+ function focusRow(el: HTMLElement) {
133
+ tabStopEl = el;
134
+ }
135
+
136
+ function navigate(el: HTMLElement, key: string) {
137
+ if (entries.length === 0) return;
138
+ const idx = entries.findIndex((e) => e.el === el);
139
+ 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));
147
+ const target = entries[targetIdx]?.el;
148
+ if (target) {
149
+ tabStopEl = target;
150
+ target.focus();
151
+ }
152
+ }
153
+
154
+ const context: SelectableListContext = {
155
+ managed: true,
156
+ itemRole: "option",
157
+ register,
158
+ isSelected,
159
+ isTabStop,
160
+ activate,
161
+ focusRow,
162
+ navigate
163
+ };
164
+ setContext(SELECTABLE_LIST_KEY, context);
165
+
166
+ const classes = $derived(["st-selectableList", className].filter(Boolean).join(" "));
167
+ </script>
168
+
169
+ <div
170
+ class={classes}
171
+ role="listbox"
172
+ aria-label={labelledby ? undefined : label}
173
+ aria-labelledby={labelledby}
174
+ aria-multiselectable={multiple ? "true" : undefined}
175
+ >
176
+ {@render children?.()}
177
+ </div>
178
+
179
+ <style>
180
+ .st-selectableList {
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: var(--st-spacing-1, 0.25rem);
184
+ width: 100%;
185
+ }
186
+ </style>
@@ -0,0 +1,30 @@
1
+ import type { Snippet } from "svelte";
2
+ export type SelectableListProps = {
3
+ /** Accessible name for the listbox (required for SR users). */
4
+ label?: string;
5
+ /** References the id of an external visible label (alternative to `label`). */
6
+ labelledby?: string;
7
+ /**
8
+ * Allow more than one selected row. Adds aria-multiselectable and toggles
9
+ * each row independently. Defaults to false (single-select).
10
+ */
11
+ multiple?: boolean;
12
+ /**
13
+ * Selected value(s). Controlled when provided. For single-select pass a
14
+ * string (or null); for multiple pass a string[]. When omitted the list is
15
+ * uncontrolled and keeps its own internal selection.
16
+ */
17
+ value?: string | string[] | null;
18
+ /**
19
+ * Fired with the new selection on every change. Receives a string|null for
20
+ * single-select and a string[] for multiple. Required for the controlled
21
+ * pattern; also fires for uncontrolled lists.
22
+ */
23
+ onchange?: (value: string | string[] | null) => void;
24
+ class?: string;
25
+ children?: Snippet;
26
+ };
27
+ declare const SelectableList: import("svelte").Component<SelectableListProps, {}, "">;
28
+ type SelectableList = ReturnType<typeof SelectableList>;
29
+ export default SelectableList;
30
+ //# sourceMappingURL=SelectableList.svelte.d.ts.map
@@ -0,0 +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"}