@sentropic/design-system-svelte 0.34.69 → 0.34.71

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,482 @@
1
+ import { fireEvent, render } from "@testing-library/svelte";
2
+ import { tick } from "svelte";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import TimeRangePicker from "./TimeRangePicker.svelte";
5
+ import { DEFAULT_TIME_RANGE_PRESETS, composeAbsolute, formatPresetLabel, formatTriggerLabel, parsePresetMs, resolveRelative, splitAbsolute } from "./timeRange";
6
+ // ---------------------------------------------------------------------------
7
+ // timeRange.ts — pure helpers
8
+ // ---------------------------------------------------------------------------
9
+ describe("parsePresetMs", () => {
10
+ it("parses each unit in the locked grammar", () => {
11
+ expect(parsePresetMs("30m")).toBe(30 * 60_000);
12
+ expect(parsePresetMs("1h")).toBe(3_600_000);
13
+ expect(parsePresetMs("24h")).toBe(24 * 3_600_000);
14
+ expect(parsePresetMs("7d")).toBe(7 * 86_400_000);
15
+ expect(parsePresetMs("2w")).toBe(2 * 604_800_000);
16
+ });
17
+ it("returns null for tokens outside ^(\\d+)(m|h|d|w)$", () => {
18
+ expect(parsePresetMs("abc")).toBeNull();
19
+ expect(parsePresetMs("10x")).toBeNull();
20
+ expect(parsePresetMs("10")).toBeNull();
21
+ expect(parsePresetMs("m10")).toBeNull();
22
+ expect(parsePresetMs("")).toBeNull();
23
+ expect(parsePresetMs("-10m")).toBeNull();
24
+ });
25
+ it("accepts a zero-amount token (degenerate but grammatically valid)", () => {
26
+ expect(parsePresetMs("0m")).toBe(0);
27
+ });
28
+ });
29
+ describe("resolveRelative", () => {
30
+ it("resolves to [now - duration, now] and reports the relative token", () => {
31
+ const now = 1_700_000_000_000;
32
+ const resolved = resolveRelative("1h", now);
33
+ expect(resolved).toEqual({ mode: "relative", relative: "1h", from: now - 3_600_000, to: now });
34
+ });
35
+ it("returns null for an ungrammatical token", () => {
36
+ expect(resolveRelative("bogus", Date.now())).toBeNull();
37
+ });
38
+ it("clamps `to` down to max without disabling when `from` still fits", () => {
39
+ const now = 1_700_000_000_000;
40
+ const max = now - 1_000;
41
+ const resolved = resolveRelative("24h", now, { max });
42
+ expect(resolved).not.toBeNull();
43
+ expect(resolved?.to).toBe(max);
44
+ expect(resolved?.from).toBe(now - 24 * 3_600_000);
45
+ });
46
+ it("clamps `from` up to min without disabling when `to` still fits", () => {
47
+ const now = 1_700_000_000_000;
48
+ const min = now - 10 * 60_000;
49
+ const resolved = resolveRelative("30m", now, { min });
50
+ expect(resolved).not.toBeNull();
51
+ expect(resolved?.from).toBe(min);
52
+ expect(resolved?.to).toBe(now);
53
+ });
54
+ it("returns null (disables) when the clamped window collapses (from > to)", () => {
55
+ const now = 1_700_000_000_000;
56
+ // max caps `to` two days back; the 30-minute window's unclamped `from` is
57
+ // far more recent than that -> from > to after clamping.
58
+ const max = now - 2 * 86_400_000;
59
+ expect(resolveRelative("30m", now, { max })).toBeNull();
60
+ });
61
+ });
62
+ describe("splitAbsolute", () => {
63
+ it("splits epoch ms into local YYYY-MM-DD / HH:mm parts", () => {
64
+ const from = new Date(2024, 0, 15, 9, 5).getTime();
65
+ const to = new Date(2024, 0, 16, 23, 45).getTime();
66
+ expect(splitAbsolute(from, to)).toEqual({
67
+ fromDate: "2024-01-15",
68
+ fromTime: "09:05",
69
+ toDate: "2024-01-16",
70
+ toTime: "23:45"
71
+ });
72
+ });
73
+ });
74
+ describe("composeAbsolute", () => {
75
+ it("composes a complete, ordered draft into {from,to}", () => {
76
+ const composed = composeAbsolute({
77
+ fromDate: "2024-01-01",
78
+ fromTime: "10:00",
79
+ toDate: "2024-01-02",
80
+ toTime: "11:30"
81
+ });
82
+ expect(composed).toEqual({
83
+ from: new Date(2024, 0, 1, 10, 0).getTime(),
84
+ to: new Date(2024, 0, 2, 11, 30).getTime()
85
+ });
86
+ });
87
+ it("returns null when any part is missing", () => {
88
+ expect(composeAbsolute({ fromDate: "2024-01-01", fromTime: "10:00", toDate: "2024-01-02" })).toBeNull();
89
+ expect(composeAbsolute({})).toBeNull();
90
+ });
91
+ it("returns null when from is after to", () => {
92
+ const composed = composeAbsolute({
93
+ fromDate: "2024-01-05",
94
+ fromTime: "10:00",
95
+ toDate: "2024-01-01",
96
+ toTime: "10:00"
97
+ });
98
+ expect(composed).toBeNull();
99
+ });
100
+ it("returns null on malformed date/time parts", () => {
101
+ expect(composeAbsolute({ fromDate: "2024/01/01", fromTime: "10:00", toDate: "2024-01-02", toTime: "10:00" })).toBeNull();
102
+ expect(composeAbsolute({ fromDate: "2024-01-01", fromTime: "25:00", toDate: "2024-01-02", toTime: "10:00" })).toBeNull();
103
+ });
104
+ it("accepts equal from/to (a zero-length range)", () => {
105
+ const ts = new Date(2024, 0, 1, 10, 0).getTime();
106
+ const composed = composeAbsolute({
107
+ fromDate: "2024-01-01",
108
+ fromTime: "10:00",
109
+ toDate: "2024-01-01",
110
+ toTime: "10:00"
111
+ });
112
+ expect(composed).toEqual({ from: ts, to: ts });
113
+ });
114
+ });
115
+ describe("formatPresetLabel", () => {
116
+ it("renders correct French singular/plural + gender agreement for every default preset", () => {
117
+ expect(formatPresetLabel("30m", "fr-FR")).toBe("30 dernières minutes");
118
+ expect(formatPresetLabel("1h", "fr-FR")).toBe("Dernière heure");
119
+ expect(formatPresetLabel("3h", "fr-FR")).toBe("3 dernières heures");
120
+ expect(formatPresetLabel("6h", "fr-FR")).toBe("6 dernières heures");
121
+ expect(formatPresetLabel("12h", "fr-FR")).toBe("12 dernières heures");
122
+ expect(formatPresetLabel("24h", "fr-FR")).toBe("24 dernières heures");
123
+ expect(formatPresetLabel("3d", "fr-FR")).toBe("3 derniers jours");
124
+ expect(formatPresetLabel("7d", "fr-FR")).toBe("7 derniers jours");
125
+ expect(formatPresetLabel("30d", "fr-FR")).toBe("30 derniers jours");
126
+ });
127
+ it("renders English labels, singular phrased without a leading '1'", () => {
128
+ expect(formatPresetLabel("30m", "en-US")).toBe("Last 30 minutes");
129
+ expect(formatPresetLabel("1h", "en-US")).toBe("Last hour");
130
+ expect(formatPresetLabel("7d", "en-US")).toBe("Last 7 days");
131
+ expect(formatPresetLabel("2w", "en-US")).toBe("Last 2 weeks");
132
+ });
133
+ it("falls back to the raw token for a non-grammatical custom token", () => {
134
+ expect(formatPresetLabel("custom-token", "fr-FR")).toBe("custom-token");
135
+ });
136
+ it("defaults to French when locale is omitted", () => {
137
+ expect(formatPresetLabel("1h")).toBe("Dernière heure");
138
+ });
139
+ });
140
+ describe("formatTriggerLabel", () => {
141
+ it("delegates to formatPresetLabel for a relative value", () => {
142
+ const value = { mode: "relative", relative: "3h", from: 0, to: 1 };
143
+ expect(formatTriggerLabel(value, "fr-FR")).toBe(formatPresetLabel("3h", "fr-FR"));
144
+ });
145
+ it("collapses a same-day absolute range to one date + HH:mm–HH:mm", () => {
146
+ const from = new Date(2024, 2, 10, 9, 0).getTime();
147
+ const to = new Date(2024, 2, 10, 17, 30).getTime();
148
+ const label = formatTriggerLabel({ mode: "absolute", from, to }, "fr-FR");
149
+ expect(label).toBe("10/03/2024 09:00–17:30");
150
+ });
151
+ it("shows both full dates for a multi-day absolute range", () => {
152
+ const from = new Date(2024, 2, 10, 9, 0).getTime();
153
+ const to = new Date(2024, 2, 12, 17, 30).getTime();
154
+ const label = formatTriggerLabel({ mode: "absolute", from, to }, "fr-FR");
155
+ expect(label).toContain("10/03/2024");
156
+ expect(label).toContain("12/03/2024");
157
+ expect(label).not.toContain("–17:30");
158
+ });
159
+ });
160
+ // ---------------------------------------------------------------------------
161
+ // TimeRangePicker.svelte — component behaviour
162
+ // ---------------------------------------------------------------------------
163
+ function triggerLabelText(container) {
164
+ return container.querySelector(".st-timeRangePicker__triggerLabel")?.textContent?.trim() ?? "";
165
+ }
166
+ function triggerButton(container) {
167
+ return container.querySelector(".st-timeRangePicker__trigger");
168
+ }
169
+ async function openPanel(container) {
170
+ await fireEvent.click(triggerButton(container));
171
+ }
172
+ describe("TimeRangePicker — trigger label", () => {
173
+ it("shows the French default relative label for the uncontrolled default (30m)", () => {
174
+ const { container } = render(TimeRangePicker, { props: {} });
175
+ expect(triggerLabelText(container)).toBe("30 dernières minutes");
176
+ });
177
+ it("shows an English relative label when locale='en-US'", () => {
178
+ const value = { mode: "relative", relative: "3h", from: Date.now() - 10_800_000, to: Date.now() };
179
+ const { container } = render(TimeRangePicker, { props: { value, locale: "en-US" } });
180
+ expect(triggerLabelText(container)).toBe("Last 3 hours");
181
+ });
182
+ it("collapses an absolute same-day range on the trigger", () => {
183
+ const from = new Date(2024, 2, 10, 9, 0).getTime();
184
+ const to = new Date(2024, 2, 10, 17, 30).getTime();
185
+ const { container } = render(TimeRangePicker, { props: { value: { mode: "absolute", from, to } } });
186
+ expect(triggerLabelText(container)).toBe(formatTriggerLabel({ mode: "absolute", from, to }, "fr-FR"));
187
+ });
188
+ it("honours a custom formatRange override", () => {
189
+ const { container } = render(TimeRangePicker, { props: { formatRange: () => "CUSTOM LABEL" } });
190
+ expect(triggerLabelText(container)).toBe("CUSTOM LABEL");
191
+ });
192
+ });
193
+ describe("TimeRangePicker — open/close mechanics", () => {
194
+ it("opens a role=dialog panel on trigger click and reflects aria-expanded", async () => {
195
+ const { container } = render(TimeRangePicker, { props: {} });
196
+ const trigger = triggerButton(container);
197
+ expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
198
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
199
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
200
+ await openPanel(container);
201
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
202
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
203
+ });
204
+ it("does not open when disabled", async () => {
205
+ const { container } = render(TimeRangePicker, { props: { disabled: true } });
206
+ const trigger = triggerButton(container);
207
+ expect(trigger.disabled).toBe(true);
208
+ await fireEvent.click(trigger);
209
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
210
+ });
211
+ it("Escape closes the panel without emitting and returns focus to the trigger", async () => {
212
+ const onChange = vi.fn();
213
+ const { container } = render(TimeRangePicker, { props: { onChange } });
214
+ const trigger = triggerButton(container);
215
+ trigger.focus();
216
+ await openPanel(container);
217
+ await tick();
218
+ await tick();
219
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
220
+ await fireEvent.keyDown(window, { key: "Escape" });
221
+ await tick();
222
+ await tick();
223
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
224
+ expect(onChange).not.toHaveBeenCalled();
225
+ expect(document.activeElement).toBe(trigger);
226
+ });
227
+ it("an outside pointerdown closes the panel without emitting", async () => {
228
+ const onChange = vi.fn();
229
+ const { container } = render(TimeRangePicker, { props: { onChange } });
230
+ await openPanel(container);
231
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
232
+ await fireEvent.pointerDown(document.body);
233
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
234
+ expect(onChange).not.toHaveBeenCalled();
235
+ });
236
+ it("moves focus inside the dialog when it opens", async () => {
237
+ const { container } = render(TimeRangePicker, { props: {} });
238
+ const trigger = triggerButton(container);
239
+ await openPanel(container);
240
+ await tick();
241
+ await tick();
242
+ await tick();
243
+ const dialog = container.querySelector('[role="dialog"]');
244
+ expect(dialog.contains(document.activeElement)).toBe(true);
245
+ expect(document.activeElement).not.toBe(trigger);
246
+ });
247
+ it("traps Tab navigation within the panel (Shift+Tab wraps last -> first)", async () => {
248
+ const { container } = render(TimeRangePicker, { props: {} });
249
+ await openPanel(container);
250
+ await tick();
251
+ await tick();
252
+ await tick();
253
+ const dialog = container.querySelector('[role="dialog"]');
254
+ const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
255
+ const focusables = Array.from(dialog.querySelectorAll(focusableSelector));
256
+ expect(focusables.length).toBeGreaterThan(1);
257
+ const first = focusables[0];
258
+ const last = focusables[focusables.length - 1];
259
+ first.focus();
260
+ await fireEvent.keyDown(first, { key: "Tab", shiftKey: true });
261
+ expect(document.activeElement).toBe(last);
262
+ });
263
+ });
264
+ describe("TimeRangePicker — relative presets", () => {
265
+ it("clicking a preset resolves now, emits {mode:'relative',...}, and closes", async () => {
266
+ const onChange = vi.fn();
267
+ const { container } = render(TimeRangePicker, { props: { onChange } });
268
+ await openPanel(container);
269
+ const before = Date.now();
270
+ const row = container.querySelector('[data-value="1h"]');
271
+ await fireEvent.click(row);
272
+ const after = Date.now();
273
+ expect(onChange).toHaveBeenCalledTimes(1);
274
+ const emitted = onChange.mock.calls[0][0];
275
+ expect(emitted.mode).toBe("relative");
276
+ expect(emitted.relative).toBe("1h");
277
+ const tolerance = 25;
278
+ expect(emitted.to).toBeGreaterThanOrEqual(before - tolerance);
279
+ expect(emitted.to).toBeLessThanOrEqual(after + tolerance);
280
+ expect(emitted.from).toBeGreaterThanOrEqual(before - 3_600_000 - tolerance);
281
+ expect(emitted.from).toBeLessThanOrEqual(after - 3_600_000 + tolerance);
282
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
283
+ });
284
+ it("re-clicking the already-active preset still refreshes now and closes", async () => {
285
+ const onChange = vi.fn();
286
+ const value = { mode: "relative", relative: "1h", from: Date.now() - 3_601_000, to: Date.now() - 1_000 };
287
+ const { container } = render(TimeRangePicker, { props: { value, onChange } });
288
+ await openPanel(container);
289
+ const row = container.querySelector('[data-value="1h"]');
290
+ expect(row.getAttribute("aria-selected")).toBe("true");
291
+ await fireEvent.click(row);
292
+ expect(onChange).toHaveBeenCalledTimes(1);
293
+ expect(onChange.mock.calls[0][0].relative).toBe("1h");
294
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
295
+ });
296
+ it("does not emit or close when clicking a disabled preset", async () => {
297
+ const onChange = vi.fn();
298
+ const now = Date.now();
299
+ const { container } = render(TimeRangePicker, {
300
+ props: { presets: ["30m"], max: now - 2 * 86_400_000, onChange }
301
+ });
302
+ await openPanel(container);
303
+ const row = container.querySelector('[data-value="30m"]');
304
+ expect(row.getAttribute("aria-disabled")).toBe("true");
305
+ await fireEvent.click(row);
306
+ expect(onChange).not.toHaveBeenCalled();
307
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
308
+ });
309
+ it("accepts object-form presets with an explicit label and durationMs override", async () => {
310
+ const onChange = vi.fn();
311
+ const { container } = render(TimeRangePicker, {
312
+ props: { presets: [{ token: "custom", label: "Custom 45", durationMs: 45 * 60_000 }], onChange }
313
+ });
314
+ await openPanel(container);
315
+ const row = container.querySelector('[data-value="custom"]');
316
+ expect(row.textContent?.trim()).toBe("Custom 45");
317
+ await fireEvent.click(row);
318
+ expect(onChange).toHaveBeenCalledTimes(1);
319
+ const emitted = onChange.mock.calls[0][0];
320
+ expect(emitted.relative).toBe("custom");
321
+ expect(Math.abs(emitted.to - emitted.from - 45 * 60_000)).toBeLessThan(50);
322
+ });
323
+ it("honours a custom formatPresetLabel override for preset rows", async () => {
324
+ const { container } = render(TimeRangePicker, {
325
+ props: { presets: ["1h"], formatPresetLabel: (token) => `>>${token}<<` }
326
+ });
327
+ await openPanel(container);
328
+ const row = container.querySelector('[data-value="1h"]');
329
+ expect(row?.textContent?.trim()).toBe(">>1h<<");
330
+ });
331
+ it("uses DEFAULT_TIME_RANGE_PRESETS when `presets` is omitted", async () => {
332
+ const { container } = render(TimeRangePicker, { props: {} });
333
+ await openPanel(container);
334
+ for (const token of DEFAULT_TIME_RANGE_PRESETS) {
335
+ expect(container.querySelector(`[data-value="${token}"]`)).toBeTruthy();
336
+ }
337
+ });
338
+ });
339
+ describe("TimeRangePicker — custom tab: Apply/Cancel", () => {
340
+ it("Apply emits {mode:'absolute'} with no `relative`, from<=to, and closes", async () => {
341
+ const onChange = vi.fn();
342
+ const { container, getByRole, getByLabelText } = render(TimeRangePicker, {
343
+ props: { onChange, locale: "en-US" }
344
+ });
345
+ await openPanel(container);
346
+ await fireEvent.click(getByRole("tab", { name: "Custom" }));
347
+ const fromInput = getByLabelText("From");
348
+ const toInput = getByLabelText("To");
349
+ await fireEvent.input(fromInput, { target: { value: "2024-01-01 10:00" } });
350
+ await fireEvent.blur(fromInput);
351
+ await fireEvent.input(toInput, { target: { value: "2024-01-02 11:30" } });
352
+ await fireEvent.blur(toInput);
353
+ const applyBtn = getByRole("button", { name: "Apply" });
354
+ expect(applyBtn.disabled).toBe(false);
355
+ await fireEvent.click(applyBtn);
356
+ expect(onChange).toHaveBeenCalledTimes(1);
357
+ const emitted = onChange.mock.calls[0][0];
358
+ expect(emitted.mode).toBe("absolute");
359
+ expect(emitted.relative).toBeUndefined();
360
+ expect(emitted.from).toBeLessThanOrEqual(emitted.to);
361
+ expect(emitted.from).toBe(new Date(2024, 0, 1, 10, 0).getTime());
362
+ expect(emitted.to).toBe(new Date(2024, 0, 2, 11, 30).getTime());
363
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
364
+ });
365
+ it("Cancel discards the draft and emits nothing", async () => {
366
+ const onChange = vi.fn();
367
+ const { container, getByRole, getByLabelText } = render(TimeRangePicker, {
368
+ props: { onChange, locale: "en-US" }
369
+ });
370
+ await openPanel(container);
371
+ await fireEvent.click(getByRole("tab", { name: "Custom" }));
372
+ const fromInput = getByLabelText("From");
373
+ await fireEvent.input(fromInput, { target: { value: "2024-01-01 10:00" } });
374
+ await fireEvent.blur(fromInput);
375
+ await fireEvent.click(getByRole("button", { name: "Cancel" }));
376
+ expect(onChange).not.toHaveBeenCalled();
377
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
378
+ });
379
+ it("disables Apply and shows an inline error when from > to", async () => {
380
+ const { container, getByRole, getByLabelText } = render(TimeRangePicker, { props: { locale: "en-US" } });
381
+ await openPanel(container);
382
+ await fireEvent.click(getByRole("tab", { name: "Custom" }));
383
+ const fromInput = getByLabelText("From");
384
+ const toInput = getByLabelText("To");
385
+ await fireEvent.input(fromInput, { target: { value: "2024-01-05 10:00" } });
386
+ await fireEvent.blur(fromInput);
387
+ await fireEvent.input(toInput, { target: { value: "2024-01-01 10:00" } });
388
+ await fireEvent.blur(toInput);
389
+ const applyBtn = getByRole("button", { name: "Apply" });
390
+ expect(applyBtn.disabled).toBe(true);
391
+ expect(container.querySelector(".st-timeRangePicker__error")?.textContent).toContain("before");
392
+ });
393
+ it("disables Apply while the draft is incomplete (half-picked calendar range)", async () => {
394
+ const { container, getByRole } = render(TimeRangePicker, { props: { locale: "en-US" } });
395
+ await openPanel(container);
396
+ await fireEvent.click(getByRole("tab", { name: "Custom" }));
397
+ const days = Array.from(container.querySelectorAll(".st-calendar__day:not(.st-calendar__day--outside):not(.st-calendar__day--today)")).filter((d) => !d.disabled);
398
+ expect(days.length).toBeGreaterThan(0);
399
+ await fireEvent.click(days[0]);
400
+ const applyBtn = getByRole("button", { name: "Apply" });
401
+ expect(applyBtn.disabled).toBe(true);
402
+ expect(container.querySelector(".st-timeRangePicker__error")).toBeTruthy();
403
+ });
404
+ it("renders one Calendar when calendarMonths=1", async () => {
405
+ const { container, getByRole } = render(TimeRangePicker, { props: { calendarMonths: 1 } });
406
+ await openPanel(container);
407
+ await fireEvent.click(getByRole("tab", { name: "Personnalisé" }));
408
+ expect(container.querySelectorAll(".st-calendar").length).toBe(1);
409
+ });
410
+ it("renders two side-by-side Calendars by default (calendarMonths=2)", async () => {
411
+ const { container, getByRole } = render(TimeRangePicker, { props: {} });
412
+ await openPanel(container);
413
+ await fireEvent.click(getByRole("tab", { name: "Personnalisé" }));
414
+ expect(container.querySelectorAll(".st-calendar").length).toBe(2);
415
+ });
416
+ });
417
+ describe("TimeRangePicker — controlled vs uncontrolled", () => {
418
+ it("uncontrolled: manages its own state and still calls onChange", async () => {
419
+ const onChange = vi.fn();
420
+ const { container } = render(TimeRangePicker, { props: { onChange } });
421
+ await openPanel(container);
422
+ const row = container.querySelector('[data-value="3h"]');
423
+ await fireEvent.click(row);
424
+ expect(onChange).toHaveBeenCalledTimes(1);
425
+ expect(triggerLabelText(container)).toBe(formatPresetLabel("3h", "fr-FR"));
426
+ });
427
+ it("controlled: reflects a `value` pushed by the consumer via rerender", async () => {
428
+ const onChange = vi.fn();
429
+ const v1 = { mode: "absolute", from: new Date(2024, 0, 1, 8, 0).getTime(), to: new Date(2024, 0, 1, 9, 0).getTime() };
430
+ const { container, rerender } = render(TimeRangePicker, { props: { value: v1, onChange } });
431
+ expect(triggerLabelText(container)).toBe(formatTriggerLabel(v1, "fr-FR"));
432
+ const v2 = { mode: "relative", relative: "1h", from: Date.now() - 3_600_000, to: Date.now() };
433
+ await rerender({ value: v2, onChange });
434
+ expect(triggerLabelText(container)).toBe(formatPresetLabel("1h", "fr-FR"));
435
+ });
436
+ it("controlled: clicking a preset notifies onChange with the resolved value", async () => {
437
+ const onChange = vi.fn();
438
+ // Relative mode so the panel opens straight on the Relative tab (opening
439
+ // seeds the tab from the current mode) and the preset rows are visible.
440
+ const value = { mode: "relative", relative: "30m", from: Date.now() - 1_800_000, to: Date.now() };
441
+ const { container } = render(TimeRangePicker, { props: { value, onChange } });
442
+ await openPanel(container);
443
+ const row = container.querySelector('[data-value="6h"]');
444
+ await fireEvent.click(row);
445
+ expect(onChange).toHaveBeenCalledTimes(1);
446
+ expect(onChange.mock.calls[0][0].relative).toBe("6h");
447
+ });
448
+ });
449
+ describe("TimeRangePicker — min/max clamping", () => {
450
+ it("disables a preset that resolves entirely outside the max bound", async () => {
451
+ const now = Date.now();
452
+ const { container } = render(TimeRangePicker, {
453
+ props: { presets: ["30m", "24h"], max: now - 2 * 86_400_000 }
454
+ });
455
+ await openPanel(container);
456
+ const shortRow = container.querySelector('[data-value="30m"]');
457
+ expect(shortRow.getAttribute("aria-disabled")).toBe("true");
458
+ });
459
+ it("keeps a preset enabled (clamped) when only part of its window is out of bounds", async () => {
460
+ const now = Date.now();
461
+ const { container } = render(TimeRangePicker, {
462
+ props: { presets: ["24h"], min: now - 60_000 }
463
+ });
464
+ await openPanel(container);
465
+ const row = container.querySelector('[data-value="24h"]');
466
+ expect(row.getAttribute("aria-disabled")).toBeNull();
467
+ });
468
+ });
469
+ describe("TimeRangePicker — misc props", () => {
470
+ it("merges a custom class and applies the size modifier", () => {
471
+ const { container } = render(TimeRangePicker, { props: { class: "mine", size: "lg" } });
472
+ const root = container.querySelector(".st-timeRangePicker");
473
+ expect(root.classList.contains("mine")).toBe(true);
474
+ expect(root.classList.contains("st-timeRangePicker--lg")).toBe(true);
475
+ });
476
+ it("renders the field label and wires it into the trigger's accessible name", () => {
477
+ const { container } = render(TimeRangePicker, { props: { label: "Time window" } });
478
+ expect(container.querySelector(".st-timeRangePicker__label")?.textContent).toBe("Time window");
479
+ const trigger = triggerButton(container);
480
+ expect(trigger.getAttribute("aria-labelledby")).toBeTruthy();
481
+ });
482
+ });
@@ -0,0 +1,40 @@
1
+ import type { Component } from "svelte";
2
+ /** Props a canonical glyph accepts. Kept minimal so the dynamic component type
3
+ * stays simple (lucide's own prop unions are too large to index over). */
4
+ export interface IconGlyphProps {
5
+ size?: number;
6
+ strokeWidth?: number;
7
+ color?: string | null;
8
+ class?: string;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Canonical Sentropic icon set (day-one).
13
+ *
14
+ * The DS PRESCRIBES one icon set and exposes it under DS-owned names via the
15
+ * {@link Icon} component. The visual source is lucide (stroke-24), which is
16
+ * already the de-facto internal standard across the DS, but consumers address
17
+ * icons by these stable DS names — never by lucide component names — so the
18
+ * source can evolve without breaking the public API.
19
+ *
20
+ * Names are ADDITIVE: new glyphs extend this map; existing names never change
21
+ * meaning. The full set is frozen against the cross-consumer glyph inventory.
22
+ */
23
+ declare const GLYPHS: {
24
+ settings: Component<import("@lucide/svelte").IconProps, {}, "">;
25
+ eye: Component<import("@lucide/svelte").IconProps, {}, "">;
26
+ "eye-off": Component<import("@lucide/svelte").IconProps, {}, "">;
27
+ layers: Component<import("@lucide/svelte").IconProps, {}, "">;
28
+ target: Component<import("@lucide/svelte").IconProps, {}, "">;
29
+ close: Component<import("@lucide/svelte").IconProps, {}, "">;
30
+ "chevron-down": Component<import("@lucide/svelte").IconProps, {}, "">;
31
+ "chevron-right": Component<import("@lucide/svelte").IconProps, {}, "">;
32
+ };
33
+ /** A canonical DS icon name accepted by {@link Icon}. */
34
+ export type IconName = keyof typeof GLYPHS;
35
+ /** Canonical name → glyph component, addressed by DS-owned {@link IconName}. */
36
+ export declare const ICONS: Record<IconName, Component<IconGlyphProps>>;
37
+ /** All canonical icon names, ordered — useful for docs/catalogs. */
38
+ export declare const ICON_NAMES: IconName[];
39
+ export {};
40
+ //# sourceMappingURL=icons.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icons.d.ts","sourceRoot":"","sources":["../src/lib/icons.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAExC;0EAC0E;AAC1E,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;;GAWG;AACH,QAAA,MAAM,MAAM;;;;;;;;;CASX,CAAC;AAEF,yDAAyD;AACzD,MAAM,MAAM,QAAQ,GAAG,MAAM,OAAO,MAAM,CAAC;AAE3C,gFAAgF;AAChF,eAAO,MAAM,KAAK,EAAa,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC;AAE3E,oEAAoE;AACpE,eAAO,MAAM,UAAU,EAAyB,QAAQ,EAAE,CAAC"}
package/dist/icons.js ADDED
@@ -0,0 +1,27 @@
1
+ import { Settings, Eye, EyeOff, Layers, Target, X, ChevronDown, ChevronRight, } from "@lucide/svelte";
2
+ /**
3
+ * Canonical Sentropic icon set (day-one).
4
+ *
5
+ * The DS PRESCRIBES one icon set and exposes it under DS-owned names via the
6
+ * {@link Icon} component. The visual source is lucide (stroke-24), which is
7
+ * already the de-facto internal standard across the DS, but consumers address
8
+ * icons by these stable DS names — never by lucide component names — so the
9
+ * source can evolve without breaking the public API.
10
+ *
11
+ * Names are ADDITIVE: new glyphs extend this map; existing names never change
12
+ * meaning. The full set is frozen against the cross-consumer glyph inventory.
13
+ */
14
+ const GLYPHS = {
15
+ settings: Settings,
16
+ eye: Eye,
17
+ "eye-off": EyeOff,
18
+ layers: Layers,
19
+ target: Target,
20
+ close: X,
21
+ "chevron-down": ChevronDown,
22
+ "chevron-right": ChevronRight,
23
+ };
24
+ /** Canonical name → glyph component, addressed by DS-owned {@link IconName}. */
25
+ export const ICONS = GLYPHS;
26
+ /** All canonical icon names, ordered — useful for docs/catalogs. */
27
+ export const ICON_NAMES = Object.keys(ICONS);
package/dist/index.d.ts CHANGED
@@ -130,6 +130,12 @@ export { default as HistogramChart } from "./HistogramChart.svelte";
130
130
  export { default as DisplaySettings } from "./DisplaySettings.svelte";
131
131
  export { default as MediaContent } from "./MediaContent.svelte";
132
132
  export { default as Transcription } from "./Transcription.svelte";
133
+ export { default as TimeRangePicker } from "./TimeRangePicker.svelte";
134
+ export { DEFAULT_TIME_RANGE_PRESETS, parsePresetMs, resolveRelative, splitAbsolute, composeAbsolute, formatPresetLabel, formatTriggerLabel, } from "./timeRange.js";
135
+ export type { TimeRange, TimeRangeMode, TimeRangePreset, AbsoluteDraft, AbsoluteSplit, } from "./timeRange.js";
136
+ export { default as Icon } from "./Icon.svelte";
137
+ export { ICONS, ICON_NAMES } from "./icons.js";
138
+ export type { IconName } from "./icons.js";
133
139
  export { default as IconButton } from "./IconButton.svelte";
134
140
  export { default as Inline } from "./Inline.svelte";
135
141
  export { default as InlineLoading } from "./InlineLoading.svelte";
@@ -207,7 +213,7 @@ export { default as Tabs } from "./Tabs.svelte";
207
213
  export { default as Tag } from "./Tag.svelte";
208
214
  export { default as ThemeProvider } from "./ThemeProvider.svelte";
209
215
  export { default as AppShell } from "./AppShell.svelte";
210
- export type { AppShellProps, AppShellUtilityMode, AppShellUtilitySide, AppShellVariant } from "./AppShell.svelte";
216
+ export type { AppShellPanelCollapse, AppShellProps, AppShellUtilityMode, AppShellUtilitySide, AppShellVariant } from "./AppShell.svelte";
211
217
  export { default as IdentityButton } from "./IdentityButton.svelte";
212
218
  export type { SiteConfig, SiteNavItem, ThemeOption, ColorMode, ActionMode } from "./site-config";
213
219
  export { SITE_CONFIG_SCHEMA_VERSION, SHELL_TOKEN_CONTRACT } from "./site-config";