@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.
- package/dist/AppShell.panelCollapse.test.d.ts +2 -0
- package/dist/AppShell.panelCollapse.test.d.ts.map +1 -0
- package/dist/AppShell.panelCollapse.test.js +129 -0
- package/dist/AppShell.svelte +216 -4
- package/dist/AppShell.svelte.d.ts +20 -0
- package/dist/AppShell.svelte.d.ts.map +1 -1
- package/dist/Icon.svelte +44 -0
- package/dist/Icon.svelte.d.ts +20 -0
- package/dist/Icon.svelte.d.ts.map +1 -0
- package/dist/Icon.test.d.ts +2 -0
- package/dist/Icon.test.d.ts.map +1 -0
- package/dist/Icon.test.js +38 -0
- package/dist/TimeRangePicker.svelte +618 -0
- package/dist/TimeRangePicker.svelte.d.ts +36 -0
- package/dist/TimeRangePicker.svelte.d.ts.map +1 -0
- package/dist/TimeRangePicker.test.d.ts +2 -0
- package/dist/TimeRangePicker.test.d.ts.map +1 -0
- package/dist/TimeRangePicker.test.js +482 -0
- package/dist/icons.d.ts +40 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +27 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/timeRange.d.ts +75 -0
- package/dist/timeRange.d.ts.map +1 -0
- package/dist/timeRange.js +198 -0
- package/package.json +1 -1
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
// TimeRangePicker — value contract. Kept identical to timeRange.ts's types
|
|
3
|
+
// (re-exported here so consumers can `import type { TimeRange } from
|
|
4
|
+
// "@sentropic/design-system-svelte"` from the component's own module block,
|
|
5
|
+
// matching the Svelte convention used across the library).
|
|
6
|
+
export type TimeRangeMode = "relative" | "absolute";
|
|
7
|
+
export type TimeRange = {
|
|
8
|
+
mode: TimeRangeMode;
|
|
9
|
+
relative?: string;
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
};
|
|
13
|
+
export type TimeRangePreset =
|
|
14
|
+
| string
|
|
15
|
+
| { token: string; label?: string; durationMs?: number };
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
// Composes EXISTING DS primitives — no new low-level control is introduced.
|
|
20
|
+
// Trigger is bespoke (there is no generic "field trigger button" primitive);
|
|
21
|
+
// everything else (popover placement/outside-click/Escape, tabs, list,
|
|
22
|
+
// calendar, time-of-day, text input, buttons) is delegated. MenuPopover does
|
|
23
|
+
// NOT trap focus or return it to the trigger on close (see its own docs), so
|
|
24
|
+
// both are added here (mirrors Modal.svelte's trapFocus pattern).
|
|
25
|
+
import { tick, untrack } from "svelte";
|
|
26
|
+
import { Clock, ChevronDown } from "@lucide/svelte";
|
|
27
|
+
import MenuPopover from "./MenuPopover.svelte";
|
|
28
|
+
import ContentSwitcher from "./ContentSwitcher.svelte";
|
|
29
|
+
import SelectableList from "./SelectableList.svelte";
|
|
30
|
+
import SelectableRow from "./SelectableRow.svelte";
|
|
31
|
+
import Calendar from "./Calendar.svelte";
|
|
32
|
+
import type { CalendarValue } from "./Calendar.svelte";
|
|
33
|
+
import TimePicker from "./TimePicker.svelte";
|
|
34
|
+
import Input from "./Input.svelte";
|
|
35
|
+
import Button from "./Button.svelte";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_TIME_RANGE_PRESETS,
|
|
38
|
+
parsePresetMs,
|
|
39
|
+
resolveRelative,
|
|
40
|
+
splitAbsolute,
|
|
41
|
+
composeAbsolute,
|
|
42
|
+
formatTriggerLabel as defaultFormatTriggerLabel,
|
|
43
|
+
formatPresetLabel as defaultFormatPresetLabel
|
|
44
|
+
} from "./timeRange";
|
|
45
|
+
|
|
46
|
+
type TimeRangePickerProps = {
|
|
47
|
+
value?: TimeRange;
|
|
48
|
+
defaultValue?: TimeRange;
|
|
49
|
+
onChange?: (value: TimeRange) => void;
|
|
50
|
+
presets?: TimeRangePreset[];
|
|
51
|
+
min?: number;
|
|
52
|
+
max?: number;
|
|
53
|
+
locale?: string;
|
|
54
|
+
timeFormat?: "24" | "12";
|
|
55
|
+
timeStep?: number;
|
|
56
|
+
calendarMonths?: 1 | 2;
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
label?: string;
|
|
59
|
+
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
|
60
|
+
align?: "start" | "end" | "center";
|
|
61
|
+
size?: "sm" | "md" | "lg";
|
|
62
|
+
class?: string;
|
|
63
|
+
formatRange?: (value: TimeRange, locale: string) => string;
|
|
64
|
+
formatPresetLabel?: (token: string, locale: string) => string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let {
|
|
68
|
+
value = $bindable(),
|
|
69
|
+
defaultValue,
|
|
70
|
+
onChange,
|
|
71
|
+
presets = DEFAULT_TIME_RANGE_PRESETS,
|
|
72
|
+
min,
|
|
73
|
+
max,
|
|
74
|
+
locale = "fr-FR",
|
|
75
|
+
timeFormat = "24",
|
|
76
|
+
timeStep = 15,
|
|
77
|
+
calendarMonths = 2,
|
|
78
|
+
disabled = false,
|
|
79
|
+
label,
|
|
80
|
+
placement = "bottom-start",
|
|
81
|
+
align,
|
|
82
|
+
size = "md",
|
|
83
|
+
class: className,
|
|
84
|
+
formatRange,
|
|
85
|
+
formatPresetLabel: formatPresetLabelProp
|
|
86
|
+
}: TimeRangePickerProps = $props();
|
|
87
|
+
|
|
88
|
+
const isFr = $derived((locale ?? "fr-FR").toLowerCase().startsWith("fr"));
|
|
89
|
+
|
|
90
|
+
function computeDefaultValue(): TimeRange {
|
|
91
|
+
if (defaultValue) return defaultValue;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const resolved = resolveRelative("30m", now, { min, max });
|
|
94
|
+
return resolved ?? { mode: "relative", relative: "30m", from: now - 30 * 60_000, to: now };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Controlled/uncontrolled: mirrors DatePicker.svelte — `value` is the single
|
|
98
|
+
// source of truth; when the consumer never sets it, seed it once so the rest
|
|
99
|
+
// of the component can just read `value` directly.
|
|
100
|
+
$effect.pre(() => {
|
|
101
|
+
if (value !== undefined) return;
|
|
102
|
+
value = computeDefaultValue();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Guards the (unreachable in practice, but type-safe) first-paint window
|
|
106
|
+
// before the $effect.pre above has committed.
|
|
107
|
+
const current = $derived<TimeRange>(value ?? computeDefaultValue());
|
|
108
|
+
|
|
109
|
+
const triggerLabel = $derived(
|
|
110
|
+
formatRange ? formatRange(current, locale) : defaultFormatTriggerLabel(current, locale)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
function resolvePresetLabel(token: string): string {
|
|
114
|
+
return formatPresetLabelProp
|
|
115
|
+
? formatPresetLabelProp(token, locale)
|
|
116
|
+
: defaultFormatPresetLabel(token, locale);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolves ONE preset entry (string or {token,durationMs}) against `now`. */
|
|
120
|
+
function resolvePresetEntry(preset: TimeRangePreset, now: number): TimeRange | null {
|
|
121
|
+
const token = typeof preset === "string" ? preset : preset.token;
|
|
122
|
+
const durationMs = typeof preset === "string" ? parsePresetMs(token) : (preset.durationMs ?? parsePresetMs(token));
|
|
123
|
+
if (durationMs == null) return null;
|
|
124
|
+
let to = now;
|
|
125
|
+
let from = now - durationMs;
|
|
126
|
+
if (max != null) to = Math.min(to, max);
|
|
127
|
+
if (min != null) from = Math.max(from, min);
|
|
128
|
+
if (from > to) return null;
|
|
129
|
+
return { mode: "relative", relative: token, from, to };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type NormalizedPreset = { token: string; label: string; disabled: boolean };
|
|
133
|
+
|
|
134
|
+
const resolvedPresets = $derived.by<NormalizedPreset[]>(() => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
return presets.map((p) => {
|
|
137
|
+
const token = typeof p === "string" ? p : p.token;
|
|
138
|
+
const customLabel = typeof p === "string" ? undefined : p.label;
|
|
139
|
+
const label = customLabel ?? resolvePresetLabel(token);
|
|
140
|
+
const resolved = resolvePresetEntry(p, now);
|
|
141
|
+
return { token, label, disabled: resolved === null };
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const selectedPresetValue = $derived<string | null>(
|
|
146
|
+
current.mode === "relative" ? (current.relative ?? null) : null
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
function commit(next: TimeRange) {
|
|
150
|
+
value = next;
|
|
151
|
+
onChange?.(next);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onPresetSelect(next: string | string[] | null) {
|
|
155
|
+
// SelectableList is single-select here, so `next` is always a string or
|
|
156
|
+
// null in practice; the wider union is only SelectableList's shared
|
|
157
|
+
// onchange contract (it also serves `multiple` lists).
|
|
158
|
+
const nextToken = Array.isArray(next) ? (next[0] ?? null) : next;
|
|
159
|
+
// SelectableList (single-select) toggles OFF when re-activating the row
|
|
160
|
+
// that is already selected, emitting null. Re-clicking the currently
|
|
161
|
+
// active preset is exactly that case here (we seed `value` as the
|
|
162
|
+
// controlled selection), and it must still resolve+close (refresh "now")
|
|
163
|
+
// rather than no-op — recover the token from the current value.
|
|
164
|
+
const token = nextToken ?? (current.mode === "relative" ? current.relative : undefined);
|
|
165
|
+
if (!token) return;
|
|
166
|
+
const preset = presets.find((p) => (typeof p === "string" ? p : p.token) === token) ?? token;
|
|
167
|
+
const resolved = resolvePresetEntry(preset, Date.now());
|
|
168
|
+
if (!resolved) return;
|
|
169
|
+
commit(resolved);
|
|
170
|
+
panelOpen = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Panel open state + focus management ----------------------------------
|
|
174
|
+
let panelOpen = $state(false);
|
|
175
|
+
let triggerEl = $state<HTMLButtonElement | null>(null);
|
|
176
|
+
let panelWrapperEl = $state<HTMLDivElement | null>(null);
|
|
177
|
+
let activeTab = $state<TimeRangeMode>("relative");
|
|
178
|
+
|
|
179
|
+
function monthOf(iso: string, offsetMonths: number): string {
|
|
180
|
+
const [y, m] = iso.split("-").map(Number);
|
|
181
|
+
const d = new Date(y, (m - 1) + offsetMonths, 1);
|
|
182
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let leftMonth = $state<string>("");
|
|
186
|
+
let rightMonth = $state<string>("");
|
|
187
|
+
|
|
188
|
+
type Draft = { fromDate: string; fromTime: string; toDate: string; toTime: string; fromText: string; toText: string };
|
|
189
|
+
|
|
190
|
+
let draft = $state<Draft>({ fromDate: "", fromTime: "", toDate: "", toTime: "", fromText: "", toText: "" });
|
|
191
|
+
|
|
192
|
+
function seedDraft() {
|
|
193
|
+
const split = splitAbsolute(current.from, current.to);
|
|
194
|
+
draft = {
|
|
195
|
+
fromDate: split.fromDate,
|
|
196
|
+
fromTime: split.fromTime,
|
|
197
|
+
toDate: split.toDate,
|
|
198
|
+
toTime: split.toTime,
|
|
199
|
+
fromText: `${split.fromDate} ${split.fromTime}`,
|
|
200
|
+
toText: `${split.toDate} ${split.toTime}`
|
|
201
|
+
};
|
|
202
|
+
leftMonth = monthOf(split.fromDate, 0);
|
|
203
|
+
rightMonth = monthOf(split.fromDate, 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let previousFocus: HTMLElement | null = null;
|
|
207
|
+
|
|
208
|
+
const focusableSelector = [
|
|
209
|
+
"a[href]",
|
|
210
|
+
"button:not([disabled])",
|
|
211
|
+
"input:not([disabled])",
|
|
212
|
+
"select:not([disabled])",
|
|
213
|
+
"textarea:not([disabled])",
|
|
214
|
+
"[tabindex]:not([tabindex='-1'])"
|
|
215
|
+
].join(",");
|
|
216
|
+
|
|
217
|
+
function focusFirstInPanel() {
|
|
218
|
+
if (!panelWrapperEl) return;
|
|
219
|
+
const focusable = panelWrapperEl.querySelector<HTMLElement>(focusableSelector);
|
|
220
|
+
(focusable ?? panelWrapperEl).focus();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function trapFocus(event: KeyboardEvent) {
|
|
224
|
+
if (!panelWrapperEl || event.key !== "Tab") return;
|
|
225
|
+
const focusable = Array.from(panelWrapperEl.querySelectorAll<HTMLElement>(focusableSelector));
|
|
226
|
+
if (focusable.length === 0) return;
|
|
227
|
+
const first = focusable[0];
|
|
228
|
+
const last = focusable.at(-1);
|
|
229
|
+
const active = document.activeElement;
|
|
230
|
+
if (!panelWrapperEl.contains(active)) {
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
first.focus();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (event.shiftKey && active === first) {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
last?.focus();
|
|
238
|
+
} else if (!event.shiftKey && active === last) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
first.focus();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Open transition: seed the tab + draft from the current value, capture the
|
|
245
|
+
// element to restore focus to, then move focus into the panel. Close
|
|
246
|
+
// transition: restore focus to whatever had it before opening (the trigger,
|
|
247
|
+
// in the overwhelming majority of cases). Reads other than `panelOpen` are
|
|
248
|
+
// untracked so a controlled `value` change while the panel stays open does
|
|
249
|
+
// NOT clobber in-progress edits.
|
|
250
|
+
$effect(() => {
|
|
251
|
+
if (panelOpen) {
|
|
252
|
+
untrack(() => {
|
|
253
|
+
activeTab = current.mode;
|
|
254
|
+
seedDraft();
|
|
255
|
+
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
256
|
+
});
|
|
257
|
+
tick().then(focusFirstInPanel);
|
|
258
|
+
} else {
|
|
259
|
+
const el = previousFocus;
|
|
260
|
+
previousFocus = null;
|
|
261
|
+
if (el) tick().then(() => el.focus());
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
function toggle() {
|
|
266
|
+
if (disabled) return;
|
|
267
|
+
panelOpen = !panelOpen;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Tab-trap is WINDOW-scoped (mirrors Modal.svelte's own trapFocus wiring) —
|
|
271
|
+
// not an onkeydown handler on a specific div — so it never needs its own
|
|
272
|
+
// ARIA role/tabindex to satisfy the a11y linter for keyboard handlers.
|
|
273
|
+
function onWindowKeydown(event: KeyboardEvent) {
|
|
274
|
+
if (!panelOpen) return;
|
|
275
|
+
trapFocus(event);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function onCalendarChange(next: CalendarValue) {
|
|
279
|
+
if (!Array.isArray(next)) return;
|
|
280
|
+
const [startIso, endIso] = next;
|
|
281
|
+
draft.fromDate = startIso ?? "";
|
|
282
|
+
draft.toDate = endIso ?? "";
|
|
283
|
+
draft.fromText = draft.fromDate ? `${draft.fromDate} ${draft.fromTime || "00:00"}` : "";
|
|
284
|
+
draft.toText = draft.toDate ? `${draft.toDate} ${draft.toTime || "00:00"}` : "";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const calendarValue = $derived<CalendarValue>([draft.fromDate || null, draft.toDate || null]);
|
|
288
|
+
const calendarMinIso = $derived(min != null ? splitAbsolute(min, min).fromDate : undefined);
|
|
289
|
+
const calendarMaxIso = $derived(max != null ? splitAbsolute(max, max).fromDate : undefined);
|
|
290
|
+
|
|
291
|
+
function onFromTimeChange(v: string) {
|
|
292
|
+
draft.fromTime = v;
|
|
293
|
+
if (draft.fromDate) draft.fromText = `${draft.fromDate} ${v}`;
|
|
294
|
+
}
|
|
295
|
+
function onToTimeChange(v: string) {
|
|
296
|
+
draft.toTime = v;
|
|
297
|
+
if (draft.toDate) draft.toText = `${draft.toDate} ${v}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseDateTimeText(text: string): { date: string; time: string } | null {
|
|
301
|
+
const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}:\d{2})$/.exec(text.trim());
|
|
302
|
+
if (!match) return null;
|
|
303
|
+
return { date: match[1], time: match[2] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function commitTypedFrom() {
|
|
307
|
+
const parsed = parseDateTimeText(draft.fromText);
|
|
308
|
+
if (!parsed) return;
|
|
309
|
+
draft.fromDate = parsed.date;
|
|
310
|
+
draft.fromTime = parsed.time;
|
|
311
|
+
}
|
|
312
|
+
function commitTypedTo() {
|
|
313
|
+
const parsed = parseDateTimeText(draft.toText);
|
|
314
|
+
if (!parsed) return;
|
|
315
|
+
draft.toDate = parsed.date;
|
|
316
|
+
draft.toTime = parsed.time;
|
|
317
|
+
}
|
|
318
|
+
function onFromTextKeydown(event: KeyboardEvent) {
|
|
319
|
+
if (event.key === "Enter") {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
commitTypedFrom();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function onToTextKeydown(event: KeyboardEvent) {
|
|
325
|
+
if (event.key === "Enter") {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
commitTypedTo();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const composedDraft = $derived(
|
|
332
|
+
composeAbsolute({
|
|
333
|
+
fromDate: draft.fromDate,
|
|
334
|
+
fromTime: draft.fromTime,
|
|
335
|
+
toDate: draft.toDate,
|
|
336
|
+
toTime: draft.toTime
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
const draftValid = $derived(composedDraft !== null);
|
|
340
|
+
const draftIncomplete = $derived(!draft.fromDate || !draft.fromTime || !draft.toDate || !draft.toTime);
|
|
341
|
+
const draftError = $derived.by<string | null>(() => {
|
|
342
|
+
if (draftValid) return null;
|
|
343
|
+
if (draftIncomplete) {
|
|
344
|
+
return isFr
|
|
345
|
+
? "Renseignez une date et une heure pour le début et la fin."
|
|
346
|
+
: "Provide a date and time for both the start and the end.";
|
|
347
|
+
}
|
|
348
|
+
return isFr
|
|
349
|
+
? "La date de début doit précéder la date de fin."
|
|
350
|
+
: "The start date must be before the end date.";
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function onApply() {
|
|
354
|
+
if (!composedDraft) return;
|
|
355
|
+
commit({ mode: "absolute", from: composedDraft.from, to: composedDraft.to });
|
|
356
|
+
panelOpen = false;
|
|
357
|
+
}
|
|
358
|
+
function onCancel() {
|
|
359
|
+
panelOpen = false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Labels (fr/en) --------------------------------------------------------
|
|
363
|
+
const panelLabel = $derived(label ?? (isFr ? "Sélecteur de plage temporelle" : "Time range selector"));
|
|
364
|
+
const relativeTabLabel = $derived(isFr ? "Relatif" : "Relative");
|
|
365
|
+
const customTabLabel = $derived(isFr ? "Personnalisé" : "Custom");
|
|
366
|
+
const relativeListLabel = $derived(isFr ? "Plages relatives" : "Relative ranges");
|
|
367
|
+
const fromLabel = $derived(isFr ? "Début" : "From");
|
|
368
|
+
const toLabel = $derived(isFr ? "Fin" : "To");
|
|
369
|
+
const applyLabel = $derived(isFr ? "Appliquer" : "Apply");
|
|
370
|
+
const cancelLabel = $derived(isFr ? "Annuler" : "Cancel");
|
|
371
|
+
const dateTimePlaceholder = "AAAA-MM-JJ HH:mm";
|
|
372
|
+
|
|
373
|
+
const tabItems = $derived([
|
|
374
|
+
{ value: "relative", label: relativeTabLabel },
|
|
375
|
+
{ value: "absolute", label: customTabLabel }
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
function onTabChange(next: string) {
|
|
379
|
+
activeTab = next === "absolute" ? "absolute" : "relative";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const rootClasses = $derived(
|
|
383
|
+
["st-timeRangePicker", `st-timeRangePicker--${size}`, className].filter(Boolean).join(" ")
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const uid = Math.random().toString(36).slice(2, 9);
|
|
387
|
+
const fieldLabelId = `st-timerangepicker-label-${uid}`;
|
|
388
|
+
const triggerTextId = `st-timerangepicker-text-${uid}`;
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
392
|
+
|
|
393
|
+
<div class={rootClasses}>
|
|
394
|
+
{#if label}
|
|
395
|
+
<span class="st-timeRangePicker__label" id={fieldLabelId}>{label}</span>
|
|
396
|
+
{/if}
|
|
397
|
+
<button
|
|
398
|
+
bind:this={triggerEl}
|
|
399
|
+
type="button"
|
|
400
|
+
class="st-timeRangePicker__trigger"
|
|
401
|
+
aria-haspopup="dialog"
|
|
402
|
+
aria-expanded={panelOpen ? "true" : "false"}
|
|
403
|
+
aria-labelledby={label ? `${fieldLabelId} ${triggerTextId}` : undefined}
|
|
404
|
+
{disabled}
|
|
405
|
+
onclick={toggle}
|
|
406
|
+
>
|
|
407
|
+
<Clock size={16} aria-hidden="true" />
|
|
408
|
+
<span class="st-timeRangePicker__triggerLabel" id={triggerTextId}>{triggerLabel}</span>
|
|
409
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
410
|
+
</button>
|
|
411
|
+
|
|
412
|
+
<MenuPopover bind:open={panelOpen} trigger={triggerEl} {placement} {align} label={panelLabel} class="st-timeRangePicker__popover">
|
|
413
|
+
<div
|
|
414
|
+
class="st-timeRangePicker__panel"
|
|
415
|
+
bind:this={panelWrapperEl}
|
|
416
|
+
>
|
|
417
|
+
<ContentSwitcher items={tabItems} value={activeTab} onchange={onTabChange} size="sm" label={panelLabel} />
|
|
418
|
+
|
|
419
|
+
{#if activeTab === "relative"}
|
|
420
|
+
<SelectableList label={relativeListLabel} value={selectedPresetValue} onchange={onPresetSelect}>
|
|
421
|
+
{#each resolvedPresets as preset (preset.token)}
|
|
422
|
+
<SelectableRow value={preset.token} disabled={preset.disabled}>{preset.label}</SelectableRow>
|
|
423
|
+
{/each}
|
|
424
|
+
</SelectableList>
|
|
425
|
+
{:else}
|
|
426
|
+
<div class="st-timeRangePicker__custom">
|
|
427
|
+
<div class="st-timeRangePicker__calendars">
|
|
428
|
+
<Calendar
|
|
429
|
+
range
|
|
430
|
+
value={calendarValue}
|
|
431
|
+
onChange={onCalendarChange}
|
|
432
|
+
min={calendarMinIso}
|
|
433
|
+
max={calendarMaxIso}
|
|
434
|
+
{locale}
|
|
435
|
+
month={leftMonth}
|
|
436
|
+
/>
|
|
437
|
+
{#if calendarMonths === 2}
|
|
438
|
+
<Calendar
|
|
439
|
+
range
|
|
440
|
+
value={calendarValue}
|
|
441
|
+
onChange={onCalendarChange}
|
|
442
|
+
min={calendarMinIso}
|
|
443
|
+
max={calendarMaxIso}
|
|
444
|
+
{locale}
|
|
445
|
+
month={rightMonth}
|
|
446
|
+
/>
|
|
447
|
+
{/if}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div class="st-timeRangePicker__bounds">
|
|
451
|
+
<div class="st-timeRangePicker__bound">
|
|
452
|
+
<Input
|
|
453
|
+
label={fromLabel}
|
|
454
|
+
bind:value={draft.fromText}
|
|
455
|
+
placeholder={dateTimePlaceholder}
|
|
456
|
+
{size}
|
|
457
|
+
onblur={commitTypedFrom}
|
|
458
|
+
onkeydown={onFromTextKeydown}
|
|
459
|
+
/>
|
|
460
|
+
<TimePicker value={draft.fromTime} onChange={onFromTimeChange} step={timeStep} format={timeFormat} {size} />
|
|
461
|
+
</div>
|
|
462
|
+
<div class="st-timeRangePicker__bound">
|
|
463
|
+
<Input
|
|
464
|
+
label={toLabel}
|
|
465
|
+
bind:value={draft.toText}
|
|
466
|
+
placeholder={dateTimePlaceholder}
|
|
467
|
+
{size}
|
|
468
|
+
onblur={commitTypedTo}
|
|
469
|
+
onkeydown={onToTextKeydown}
|
|
470
|
+
/>
|
|
471
|
+
<TimePicker value={draft.toTime} onChange={onToTimeChange} step={timeStep} format={timeFormat} {size} />
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
{#if draftError}
|
|
476
|
+
<p class="st-timeRangePicker__error" role="alert">{draftError}</p>
|
|
477
|
+
{/if}
|
|
478
|
+
|
|
479
|
+
<div class="st-timeRangePicker__actions">
|
|
480
|
+
<Button type="button" variant="ghost" {size} onclick={onCancel}>{cancelLabel}</Button>
|
|
481
|
+
<Button type="button" variant="primary" {size} disabled={!draftValid} onclick={onApply}>{applyLabel}</Button>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
{/if}
|
|
485
|
+
</div>
|
|
486
|
+
</MenuPopover>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<style>
|
|
490
|
+
.st-timeRangePicker {
|
|
491
|
+
display: inline-flex;
|
|
492
|
+
flex-direction: column;
|
|
493
|
+
gap: var(--st-component-field-gap, 0.5rem);
|
|
494
|
+
position: relative;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.st-timeRangePicker__label {
|
|
498
|
+
color: var(--st-component-field-labelText, var(--st-semantic-text-primary));
|
|
499
|
+
font-size: var(--st-component-field-labelTypography-size, 0.875rem);
|
|
500
|
+
font-weight: var(--st-component-field-labelTypography-weight, 600);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.st-timeRangePicker__trigger {
|
|
504
|
+
align-items: center;
|
|
505
|
+
background: var(--st-component-control-background, var(--st-semantic-surface-default));
|
|
506
|
+
border: var(--st-component-control-anatomy-shape-borderWidth, 1px)
|
|
507
|
+
var(--st-component-control-anatomy-shape-borderStyle, solid)
|
|
508
|
+
var(--st-component-control-border, var(--st-semantic-border-subtle));
|
|
509
|
+
border-radius: var(--st-component-control-anatomy-shape-radius, 0.375rem);
|
|
510
|
+
color: var(--st-component-control-text, var(--st-semantic-text-primary));
|
|
511
|
+
cursor: pointer;
|
|
512
|
+
display: inline-flex;
|
|
513
|
+
font: inherit;
|
|
514
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
515
|
+
transition:
|
|
516
|
+
border-color var(--st-motion-fast, 120ms) var(--st-motion-easing, ease),
|
|
517
|
+
box-shadow var(--st-motion-fast, 120ms) var(--st-motion-easing, ease);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.st-timeRangePicker--sm .st-timeRangePicker__trigger {
|
|
521
|
+
min-height: var(--st-component-control-smHeight, 2rem);
|
|
522
|
+
padding: 0 0.625rem;
|
|
523
|
+
font-size: 0.8125rem;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.st-timeRangePicker--md .st-timeRangePicker__trigger {
|
|
527
|
+
min-height: var(--st-component-control-mdHeight, 2.5rem);
|
|
528
|
+
padding: 0 0.75rem;
|
|
529
|
+
font-size: 0.875rem;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.st-timeRangePicker--lg .st-timeRangePicker__trigger {
|
|
533
|
+
min-height: var(--st-component-control-lgHeight, 3rem);
|
|
534
|
+
padding: 0 0.875rem;
|
|
535
|
+
font-size: 1rem;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.st-timeRangePicker__trigger:hover:not(:disabled) {
|
|
539
|
+
background: var(--st-component-control-hoverBackground, var(--st-semantic-surface-subtle));
|
|
540
|
+
border-color: var(--st-component-control-hoverBorder, var(--st-semantic-border-strong));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.st-timeRangePicker__trigger:focus-visible {
|
|
544
|
+
border-color: var(--st-component-control-focusRing, var(--st-semantic-border-interactive));
|
|
545
|
+
outline: var(--st-component-control-anatomy-focus-outline, none);
|
|
546
|
+
outline-offset: var(--st-component-control-anatomy-focus-offset, 0);
|
|
547
|
+
box-shadow: var(--st-component-control-anatomy-focus-boxShadow,
|
|
548
|
+
0 0 0 2px var(--st-component-control-focusRing, var(--st-semantic-border-interactive)));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.st-timeRangePicker__trigger:disabled {
|
|
552
|
+
color: var(--st-component-control-disabledText, var(--st-semantic-text-muted));
|
|
553
|
+
cursor: not-allowed;
|
|
554
|
+
opacity: 0.65;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.st-timeRangePicker__triggerLabel {
|
|
558
|
+
max-width: 20rem;
|
|
559
|
+
overflow: hidden;
|
|
560
|
+
text-overflow: ellipsis;
|
|
561
|
+
white-space: nowrap;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* Panel content — a single stacked column under the tab switcher. NO left
|
|
565
|
+
rail (proscribed DS anti-pattern): tabs on top, one column beneath. */
|
|
566
|
+
.st-timeRangePicker__panel {
|
|
567
|
+
display: flex;
|
|
568
|
+
flex-direction: column;
|
|
569
|
+
gap: var(--st-spacing-3, 0.75rem);
|
|
570
|
+
min-width: 20rem;
|
|
571
|
+
padding: var(--st-spacing-3, 0.75rem);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.st-timeRangePicker__panel:focus {
|
|
575
|
+
outline: none;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.st-timeRangePicker__custom {
|
|
579
|
+
display: flex;
|
|
580
|
+
flex-direction: column;
|
|
581
|
+
gap: var(--st-spacing-3, 0.75rem);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.st-timeRangePicker__calendars {
|
|
585
|
+
display: flex;
|
|
586
|
+
flex-wrap: wrap;
|
|
587
|
+
gap: var(--st-spacing-3, 0.75rem);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.st-timeRangePicker__calendars :global(.st-calendar) {
|
|
591
|
+
flex: 1 1 16rem;
|
|
592
|
+
min-width: 16rem;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.st-timeRangePicker__bounds {
|
|
596
|
+
display: grid;
|
|
597
|
+
gap: var(--st-spacing-3, 0.75rem);
|
|
598
|
+
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.st-timeRangePicker__bound {
|
|
602
|
+
display: grid;
|
|
603
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.st-timeRangePicker__error {
|
|
607
|
+
color: var(--st-component-field-errorText, var(--st-semantic-feedback-error));
|
|
608
|
+
font-size: 0.8125rem;
|
|
609
|
+
line-height: 1.4;
|
|
610
|
+
margin: 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.st-timeRangePicker__actions {
|
|
614
|
+
display: flex;
|
|
615
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
616
|
+
justify-content: flex-end;
|
|
617
|
+
}
|
|
618
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type TimeRangeMode = "relative" | "absolute";
|
|
2
|
+
export type TimeRange = {
|
|
3
|
+
mode: TimeRangeMode;
|
|
4
|
+
relative?: string;
|
|
5
|
+
from: number;
|
|
6
|
+
to: number;
|
|
7
|
+
};
|
|
8
|
+
export type TimeRangePreset = string | {
|
|
9
|
+
token: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
durationMs?: number;
|
|
12
|
+
};
|
|
13
|
+
type TimeRangePickerProps = {
|
|
14
|
+
value?: TimeRange;
|
|
15
|
+
defaultValue?: TimeRange;
|
|
16
|
+
onChange?: (value: TimeRange) => void;
|
|
17
|
+
presets?: TimeRangePreset[];
|
|
18
|
+
min?: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
locale?: string;
|
|
21
|
+
timeFormat?: "24" | "12";
|
|
22
|
+
timeStep?: number;
|
|
23
|
+
calendarMonths?: 1 | 2;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
label?: string;
|
|
26
|
+
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
|
27
|
+
align?: "start" | "end" | "center";
|
|
28
|
+
size?: "sm" | "md" | "lg";
|
|
29
|
+
class?: string;
|
|
30
|
+
formatRange?: (value: TimeRange, locale: string) => string;
|
|
31
|
+
formatPresetLabel?: (token: string, locale: string) => string;
|
|
32
|
+
};
|
|
33
|
+
declare const TimeRangePicker: import("svelte").Component<TimeRangePickerProps, {}, "value">;
|
|
34
|
+
type TimeRangePicker = ReturnType<typeof TimeRangePicker>;
|
|
35
|
+
export default TimeRangePicker;
|
|
36
|
+
//# sourceMappingURL=TimeRangePicker.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimeRangePicker.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TimeRangePicker.svelte.ts"],"names":[],"mappings":"AAOE,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,CAAC;AACpD,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,aAAa,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AACF,MAAM,MAAM,eAAe,GACvB,MAAM,GACN;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AA+B3D,KAAK,oBAAoB,GAAG;IAC1B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACtC,OAAO,CAAC,EAAE,eAAe,EAAE,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;IACpE,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;IACnC,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3D,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;CAC/D,CAAC;AA0ZJ,QAAA,MAAM,eAAe,+DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimeRangePicker.test.d.ts","sourceRoot":"","sources":["../src/lib/TimeRangePicker.test.ts"],"names":[],"mappings":""}
|