@viniaraujo68/plinth 0.5.0 → 0.7.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/README.md +11 -0
- package/dist/components/DateRangePicker.svelte +144 -62
- package/dist/components/DateRangePicker.svelte.d.ts +47 -15
- package/dist/components/date-range.d.ts +82 -14
- package/dist/components/date-range.js +86 -5
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -315,6 +315,17 @@ instead of wrapping, fading at the edge while tabs are hidden past it. `value` i
|
|
|
315
315
|
that keeps its section in the URL passes it in and navigates on `onchange`, and `idPrefix` makes the
|
|
316
316
|
tab ids predictable so the panel can name its tab through `aria-labelledby`.
|
|
317
317
|
|
|
318
|
+
`DateRangePicker` picks a from/to pair from presets or two typed fields. By default it is a window
|
|
319
|
+
of instants — presets are durations back from now ("24h"), the fields are local date-times and the
|
|
320
|
+
pair goes out as UTC ISO strings. With `mode="date"` it is a span of calendar days: the fields are
|
|
321
|
+
date fields, the pair is `YYYY-MM-DD`, and an empty end leaves that side open, so "since March" and
|
|
322
|
+
"all time" are pairs too. Presets can follow the calendar instead of a duration — `thisMonth`,
|
|
323
|
+
`lastMonth`, `lastMonths(n)` and `allTime` build the usual ones, and any `range(now)` function will
|
|
324
|
+
do. Every preset re-anchors on a timer, and a picker handed a pair opens on the preset that names it.
|
|
325
|
+
`caption` puts visible text before the presets, as on `SegmentedControl`, and `size` (`sm` or `md`)
|
|
326
|
+
sizes the presets and the typed fields together, so the fields line up with the buttons.
|
|
327
|
+
`fieldLabels` puts the field labels `"above"` their fields (the default) or `"beside"` them.
|
|
328
|
+
|
|
318
329
|
`JsonInput` edits a JSON document as text: it reports through a bindable `invalid` whether the text
|
|
319
330
|
parses, shows the parser's message after the first blur, can reindent on blur, and sets the
|
|
320
331
|
textarea's custom validity so a form will not submit a broken document. `parseJsonText` and
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from "svelte";
|
|
3
3
|
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import SegmentedControl from "./SegmentedControl.svelte";
|
|
4
5
|
import {
|
|
5
6
|
anchorSelection,
|
|
6
7
|
buildRange,
|
|
@@ -8,27 +9,38 @@
|
|
|
8
9
|
CUSTOM_PRESET_ID,
|
|
9
10
|
DEFAULT_PRESETS,
|
|
10
11
|
isoToLocalInput,
|
|
12
|
+
isValidDayRange,
|
|
11
13
|
isValidRange,
|
|
12
14
|
localInputToIso,
|
|
15
|
+
parseDay,
|
|
16
|
+
selectionFor,
|
|
13
17
|
type DateRange,
|
|
18
|
+
type DateRangeMode,
|
|
14
19
|
type DateRangePreset,
|
|
15
20
|
} from "./date-range.js";
|
|
16
21
|
|
|
17
22
|
interface Props {
|
|
18
23
|
class?: ClassValue;
|
|
19
24
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
25
|
+
* `"datetime"` (the default) picks a window of instants, emitted as UTC ISO strings, with
|
|
26
|
+
* date-and-time fields. `"date"` picks a span of calendar days, emitted as `YYYY-MM-DD`, with
|
|
27
|
+
* date fields either of which may stay empty to leave that side open.
|
|
28
|
+
*/
|
|
29
|
+
mode?: DateRangeMode;
|
|
30
|
+
/**
|
|
31
|
+
* The selected window — UTC ISO instants, or days in `"date"` mode. Bindable, and also reported
|
|
32
|
+
* through {@link Props.onchange} — a consumer needs one or the other, never both.
|
|
22
33
|
*
|
|
23
34
|
* It is an output, not an input: a relative preset overwrites it on every anchor. Passing one
|
|
24
|
-
* in only seeds the picker
|
|
35
|
+
* in only seeds the picker: it opens on the preset that names that window right now, or on
|
|
36
|
+
* custom with the pair in the fields.
|
|
25
37
|
*/
|
|
26
38
|
value?: DateRange;
|
|
27
39
|
/** The buttons offered. `id` may not be `"custom"`, which the picker reserves. */
|
|
28
40
|
presets?: readonly DateRangePreset[];
|
|
29
41
|
/**
|
|
30
|
-
* Which button starts selected — a preset id, or `"custom"`. Defaults to the
|
|
31
|
-
* to custom when
|
|
42
|
+
* Which button starts selected — a preset id, or `"custom"`. Defaults to the preset matching a
|
|
43
|
+
* seeded `value`, to custom when none does, and to the first preset when there is no `value`.
|
|
32
44
|
*/
|
|
33
45
|
preset?: string;
|
|
34
46
|
/**
|
|
@@ -51,11 +63,27 @@
|
|
|
51
63
|
toLabel?: string;
|
|
52
64
|
/** Shown, as an alert, while the typed pair is out of order. */
|
|
53
65
|
invalidRangeLabel?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Visible text before the presets — "Period:". Passed to the {@link SegmentedControl}, so it
|
|
68
|
+
* sits on the preset row; `label` stays the accessible name.
|
|
69
|
+
*/
|
|
70
|
+
caption?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Sizes the presets and the custom fields together, so the fields line up with the buttons.
|
|
73
|
+
* `sm` is for a toolbar or a card header, where the default height would dominate.
|
|
74
|
+
*/
|
|
75
|
+
size?: "sm" | "md";
|
|
76
|
+
/**
|
|
77
|
+
* Where the custom fields' labels sit: `"above"` stacks each one over its field, `"beside"`
|
|
78
|
+
* puts it before the field on the same line, so the whole picker can stay one row.
|
|
79
|
+
*/
|
|
80
|
+
fieldLabels?: "above" | "beside";
|
|
54
81
|
onchange?: (value: DateRange) => void;
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
let {
|
|
58
85
|
class: cls,
|
|
86
|
+
mode = "datetime",
|
|
59
87
|
value = $bindable(),
|
|
60
88
|
presets = DEFAULT_PRESETS,
|
|
61
89
|
preset,
|
|
@@ -65,6 +93,9 @@
|
|
|
65
93
|
fromLabel = "From",
|
|
66
94
|
toLabel = "To",
|
|
67
95
|
invalidRangeLabel = "The start must not be after the end.",
|
|
96
|
+
caption,
|
|
97
|
+
size = "md",
|
|
98
|
+
fieldLabels = "above",
|
|
68
99
|
onchange,
|
|
69
100
|
}: Props = $props();
|
|
70
101
|
|
|
@@ -76,7 +107,13 @@
|
|
|
76
107
|
// The selection is the picker's own state rather than a bound prop: the pair is the entire
|
|
77
108
|
// output, and a second bound value would be a second thing for a consumer to keep in step.
|
|
78
109
|
let selection = $state(
|
|
79
|
-
untrack(
|
|
110
|
+
untrack(
|
|
111
|
+
() =>
|
|
112
|
+
preset ??
|
|
113
|
+
(value
|
|
114
|
+
? selectionFor(value, presets, Date.now(), mode)
|
|
115
|
+
: (presets[0]?.id ?? CUSTOM_PRESET_ID)),
|
|
116
|
+
),
|
|
80
117
|
);
|
|
81
118
|
|
|
82
119
|
// Wall-clock text for the two fields. Empty until custom mode is entered, because reading the
|
|
@@ -86,6 +123,15 @@
|
|
|
86
123
|
let customTo = $state("");
|
|
87
124
|
|
|
88
125
|
const isCustom = $derived(selection === CUSTOM_PRESET_ID);
|
|
126
|
+
const days = $derived(mode === "date");
|
|
127
|
+
const fieldWrapper = $derived(
|
|
128
|
+
fieldLabels === "beside" ? "flex items-center gap-2" : "flex flex-col gap-1",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const options = $derived([
|
|
132
|
+
...presets.map(({ id, label }) => ({ id, label })),
|
|
133
|
+
{ id: CUSTOM_PRESET_ID, label: customLabel },
|
|
134
|
+
]);
|
|
89
135
|
|
|
90
136
|
const emit = (next: DateRange) => {
|
|
91
137
|
if (value?.from === next.from && value?.to === next.to) return;
|
|
@@ -95,17 +141,37 @@
|
|
|
95
141
|
};
|
|
96
142
|
|
|
97
143
|
const anchor = () => {
|
|
98
|
-
const next = anchorSelection(selection, presets, Date.now());
|
|
144
|
+
const next = anchorSelection(selection, presets, Date.now(), mode);
|
|
99
145
|
if (next) emit(next);
|
|
100
146
|
};
|
|
101
147
|
|
|
102
148
|
const seedCustomFields = (range: DateRange | undefined) => {
|
|
103
|
-
|
|
104
|
-
|
|
149
|
+
const toField = days ? parseDay : isoToLocalInput;
|
|
150
|
+
customFrom = toField(range?.from) ?? "";
|
|
151
|
+
customTo = toField(range?.to) ?? "";
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The typed pair, read back: `undefined` while either field is not yet a value — half typed —
|
|
156
|
+
* and otherwise the pair with whether it is in order. In `"date"` mode an empty field is a value,
|
|
157
|
+
* an open end, where in `"datetime"` mode it is not yet one.
|
|
158
|
+
*/
|
|
159
|
+
const typedPair = (): { range: DateRange; ordered: boolean } | undefined => {
|
|
160
|
+
if (days) {
|
|
161
|
+
const from = parseDay(customFrom);
|
|
162
|
+
const to = parseDay(customTo);
|
|
163
|
+
if (from === null || to === null) return undefined;
|
|
164
|
+
return { range: { from, to }, ordered: isValidDayRange(from, to) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const from = localInputToIso(customFrom);
|
|
168
|
+
const to = localInputToIso(customTo);
|
|
169
|
+
if (from === null || to === null) return undefined;
|
|
170
|
+
return { range: { from, to }, ordered: isValidRange(from, to) };
|
|
105
171
|
};
|
|
106
172
|
|
|
107
173
|
const enterCustom = () => {
|
|
108
|
-
const current = value ?? (presets[0] ? buildRange(presets[0]) : undefined);
|
|
174
|
+
const current = value ?? (presets[0] ? buildRange(presets[0], Date.now(), mode) : undefined);
|
|
109
175
|
|
|
110
176
|
seedCustomFields(current);
|
|
111
177
|
selection = CUSTOM_PRESET_ID;
|
|
@@ -117,27 +183,26 @@
|
|
|
117
183
|
};
|
|
118
184
|
|
|
119
185
|
const commitCustom = () => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (from === null || to === null || !isValidRange(from, to)) return;
|
|
124
|
-
|
|
125
|
-
emit({ from, to });
|
|
186
|
+
const pair = typedPair();
|
|
187
|
+
if (pair?.ordered) emit(pair.range);
|
|
126
188
|
};
|
|
127
189
|
|
|
128
190
|
const rangeError = $derived.by(() => {
|
|
129
191
|
if (!isCustom) return null;
|
|
130
192
|
|
|
131
|
-
const from = localInputToIso(customFrom);
|
|
132
|
-
const to = localInputToIso(customTo);
|
|
133
|
-
|
|
134
193
|
// A half-typed field is not yet wrong, so it says nothing rather than flashing an error at
|
|
135
194
|
// someone who is still filling the form in.
|
|
136
|
-
|
|
195
|
+
const pair = typedPair();
|
|
196
|
+
if (pair === undefined) return null;
|
|
137
197
|
|
|
138
|
-
return
|
|
198
|
+
return pair.ordered ? null : invalidRangeLabel;
|
|
139
199
|
});
|
|
140
200
|
|
|
201
|
+
const pick = (id: string) => {
|
|
202
|
+
if (id === CUSTOM_PRESET_ID) enterCustom();
|
|
203
|
+
else selection = id;
|
|
204
|
+
};
|
|
205
|
+
|
|
141
206
|
$effect(() => {
|
|
142
207
|
const intervalMs = reAnchorMs;
|
|
143
208
|
if (selection === CUSTOM_PRESET_ID) return;
|
|
@@ -175,17 +240,21 @@
|
|
|
175
240
|
|
|
176
241
|
<!--
|
|
177
242
|
@component
|
|
178
|
-
A from/to picker built as a plain form control:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
243
|
+
A from/to picker built as a plain form control: presets, a custom pair the reader types, and
|
|
244
|
+
`{ from, to }` on the way out. It fetches nothing, knows no query parameter, and reads no URL — what
|
|
245
|
+
the pair means is the consumer's business.
|
|
246
|
+
|
|
247
|
+
It works in one of two modes. `"datetime"`, the default, is a window of instants: the custom
|
|
248
|
+
fields are `<input type="datetime-local">`, wall-clock time in the viewer's own zone, while the
|
|
249
|
+
emitted pair stays in UTC — the conversion runs to whole minutes in both directions; see
|
|
250
|
+
`isoToLocalInput` / `localInputToIso`. `"date"` is a span of calendar days: the fields are
|
|
251
|
+
`<input type="date">`, the pair is `YYYY-MM-DD`, and an empty end leaves that side open.
|
|
252
|
+
|
|
253
|
+
A preset is a duration back from now ("24h") or a window computed from the calendar ("this month",
|
|
254
|
+
built with `thisMonth`, `lastMonth`, `lastMonths` and `allTime`). Either re-anchors on a timer
|
|
255
|
+
(`reAnchorMs`, 60s by default, `0` to disable), so a dashboard left open keeps sliding forward and
|
|
256
|
+
"this month" turns into the next one. The timer suspends while the tab is hidden and fires once on
|
|
257
|
+
return. A custom range is never touched by it.
|
|
189
258
|
|
|
190
259
|
Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
|
|
191
260
|
`toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
|
|
@@ -194,44 +263,31 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
|
|
|
194
263
|
|
|
195
264
|
```svelte
|
|
196
265
|
<DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
|
|
266
|
+
|
|
267
|
+
<DateRangePicker
|
|
268
|
+
mode="date"
|
|
269
|
+
presets={[allTime("All time"), thisMonth("This month"), lastMonth("Last month")]}
|
|
270
|
+
onchange={(r) => load(r)}
|
|
271
|
+
/>
|
|
197
272
|
```
|
|
198
273
|
-->
|
|
199
274
|
|
|
200
|
-
<div class={["flex flex-wrap items-end gap-x-4 gap-y-3", cls]}>
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
type="button"
|
|
205
|
-
class="btn join-item min-h-11"
|
|
206
|
-
class:btn-primary={selection === option.id}
|
|
207
|
-
aria-pressed={selection === option.id}
|
|
208
|
-
onclick={() => (selection = option.id)}
|
|
209
|
-
>
|
|
210
|
-
{option.label}
|
|
211
|
-
</button>
|
|
212
|
-
{/each}
|
|
213
|
-
|
|
214
|
-
<button
|
|
215
|
-
type="button"
|
|
216
|
-
class="btn join-item min-h-11"
|
|
217
|
-
class:btn-primary={isCustom}
|
|
218
|
-
aria-pressed={isCustom}
|
|
219
|
-
onclick={enterCustom}
|
|
220
|
-
>
|
|
221
|
-
{customLabel}
|
|
222
|
-
</button>
|
|
223
|
-
</div>
|
|
275
|
+
<div class={["flex flex-wrap items-end gap-x-4 gap-y-3", cls]} data-size={size}>
|
|
276
|
+
<!-- A segmented control rather than a joined row: a calendar picker easily offers six windows,
|
|
277
|
+
and a joined row cannot wrap onto a second line at phone width. -->
|
|
278
|
+
<SegmentedControl {options} value={selection} {label} {caption} {size} onchange={pick} />
|
|
224
279
|
|
|
225
280
|
{#if isCustom}
|
|
226
281
|
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
|
|
227
|
-
<div class=
|
|
282
|
+
<div class={fieldWrapper}>
|
|
228
283
|
<label class="label text-xs" for={fromId}>{fromLabel}</label>
|
|
229
284
|
<input
|
|
230
285
|
id={fromId}
|
|
231
|
-
class="
|
|
286
|
+
class="plinth-range-field input"
|
|
232
287
|
class:input-error={rangeError !== null}
|
|
233
|
-
type="datetime-local"
|
|
288
|
+
type={days ? "date" : "datetime-local"}
|
|
234
289
|
value={customFrom}
|
|
290
|
+
max={days && customTo ? customTo : undefined}
|
|
235
291
|
aria-invalid={rangeError !== null}
|
|
236
292
|
aria-describedby={rangeError === null ? undefined : errorId}
|
|
237
293
|
oninput={({ currentTarget }) => {
|
|
@@ -241,14 +297,15 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
|
|
|
241
297
|
/>
|
|
242
298
|
</div>
|
|
243
299
|
|
|
244
|
-
<div class=
|
|
300
|
+
<div class={fieldWrapper}>
|
|
245
301
|
<label class="label text-xs" for={toId}>{toLabel}</label>
|
|
246
302
|
<input
|
|
247
303
|
id={toId}
|
|
248
|
-
class="
|
|
304
|
+
class="plinth-range-field input"
|
|
249
305
|
class:input-error={rangeError !== null}
|
|
250
|
-
type="datetime-local"
|
|
306
|
+
type={days ? "date" : "datetime-local"}
|
|
251
307
|
value={customTo}
|
|
308
|
+
min={days && customFrom ? customFrom : undefined}
|
|
252
309
|
aria-invalid={rangeError !== null}
|
|
253
310
|
aria-describedby={rangeError === null ? undefined : errorId}
|
|
254
311
|
oninput={({ currentTarget }) => {
|
|
@@ -264,3 +321,28 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
|
|
|
264
321
|
<p id={errorId} class="basis-full text-sm text-error" role="alert">{rangeError}</p>
|
|
265
322
|
{/if}
|
|
266
323
|
</div>
|
|
324
|
+
|
|
325
|
+
<style>
|
|
326
|
+
/* Chrome sizes a date field to its text alone, leaving no room for the padding and the picker
|
|
327
|
+
icon, which then covers the last digit. */
|
|
328
|
+
.plinth-range-field {
|
|
329
|
+
height: 2rem;
|
|
330
|
+
min-width: 9rem;
|
|
331
|
+
font-size: 0.78rem;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.plinth-range-field[type="datetime-local"] {
|
|
335
|
+
min-width: 12.5rem;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
[data-size="sm"] .plinth-range-field {
|
|
339
|
+
height: 1.625rem;
|
|
340
|
+
font-size: 0.7rem;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
@media (max-width: 35rem) {
|
|
344
|
+
[data-size="md"] .plinth-range-field {
|
|
345
|
+
height: 2.5rem;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
</style>
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import type { ClassValue } from "svelte/elements";
|
|
2
|
-
import { type DateRange, type DateRangePreset } from "./date-range.js";
|
|
2
|
+
import { type DateRange, type DateRangeMode, type DateRangePreset } from "./date-range.js";
|
|
3
3
|
interface Props {
|
|
4
4
|
class?: ClassValue;
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* `"datetime"` (the default) picks a window of instants, emitted as UTC ISO strings, with
|
|
7
|
+
* date-and-time fields. `"date"` picks a span of calendar days, emitted as `YYYY-MM-DD`, with
|
|
8
|
+
* date fields either of which may stay empty to leave that side open.
|
|
9
|
+
*/
|
|
10
|
+
mode?: DateRangeMode;
|
|
11
|
+
/**
|
|
12
|
+
* The selected window — UTC ISO instants, or days in `"date"` mode. Bindable, and also reported
|
|
13
|
+
* through {@link Props.onchange} — a consumer needs one or the other, never both.
|
|
8
14
|
*
|
|
9
15
|
* It is an output, not an input: a relative preset overwrites it on every anchor. Passing one
|
|
10
|
-
* in only seeds the picker
|
|
16
|
+
* in only seeds the picker: it opens on the preset that names that window right now, or on
|
|
17
|
+
* custom with the pair in the fields.
|
|
11
18
|
*/
|
|
12
19
|
value?: DateRange;
|
|
13
20
|
/** The buttons offered. `id` may not be `"custom"`, which the picker reserves. */
|
|
14
21
|
presets?: readonly DateRangePreset[];
|
|
15
22
|
/**
|
|
16
|
-
* Which button starts selected — a preset id, or `"custom"`. Defaults to the
|
|
17
|
-
* to custom when
|
|
23
|
+
* Which button starts selected — a preset id, or `"custom"`. Defaults to the preset matching a
|
|
24
|
+
* seeded `value`, to custom when none does, and to the first preset when there is no `value`.
|
|
18
25
|
*/
|
|
19
26
|
preset?: string;
|
|
20
27
|
/**
|
|
@@ -37,20 +44,39 @@ interface Props {
|
|
|
37
44
|
toLabel?: string;
|
|
38
45
|
/** Shown, as an alert, while the typed pair is out of order. */
|
|
39
46
|
invalidRangeLabel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Visible text before the presets — "Period:". Passed to the {@link SegmentedControl}, so it
|
|
49
|
+
* sits on the preset row; `label` stays the accessible name.
|
|
50
|
+
*/
|
|
51
|
+
caption?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Sizes the presets and the custom fields together, so the fields line up with the buttons.
|
|
54
|
+
* `sm` is for a toolbar or a card header, where the default height would dominate.
|
|
55
|
+
*/
|
|
56
|
+
size?: "sm" | "md";
|
|
57
|
+
/**
|
|
58
|
+
* Where the custom fields' labels sit: `"above"` stacks each one over its field, `"beside"`
|
|
59
|
+
* puts it before the field on the same line, so the whole picker can stay one row.
|
|
60
|
+
*/
|
|
61
|
+
fieldLabels?: "above" | "beside";
|
|
40
62
|
onchange?: (value: DateRange) => void;
|
|
41
63
|
}
|
|
42
64
|
/**
|
|
43
|
-
* A from/to picker built as a plain form control:
|
|
44
|
-
*
|
|
45
|
-
*
|
|
65
|
+
* A from/to picker built as a plain form control: presets, a custom pair the reader types, and
|
|
66
|
+
* `{ from, to }` on the way out. It fetches nothing, knows no query parameter, and reads no URL — what
|
|
67
|
+
* the pair means is the consumer's business.
|
|
46
68
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
69
|
+
* It works in one of two modes. `"datetime"`, the default, is a window of instants: the custom
|
|
70
|
+
* fields are `<input type="datetime-local">`, wall-clock time in the viewer's own zone, while the
|
|
71
|
+
* emitted pair stays in UTC — the conversion runs to whole minutes in both directions; see
|
|
72
|
+
* `isoToLocalInput` / `localInputToIso`. `"date"` is a span of calendar days: the fields are
|
|
73
|
+
* `<input type="date">`, the pair is `YYYY-MM-DD`, and an empty end leaves that side open.
|
|
50
74
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
75
|
+
* A preset is a duration back from now ("24h") or a window computed from the calendar ("this month",
|
|
76
|
+
* built with `thisMonth`, `lastMonth`, `lastMonths` and `allTime`). Either re-anchors on a timer
|
|
77
|
+
* (`reAnchorMs`, 60s by default, `0` to disable), so a dashboard left open keeps sliding forward and
|
|
78
|
+
* "this month" turns into the next one. The timer suspends while the tab is hidden and fires once on
|
|
79
|
+
* return. A custom range is never touched by it.
|
|
54
80
|
*
|
|
55
81
|
* Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
|
|
56
82
|
* `toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
|
|
@@ -59,6 +85,12 @@ interface Props {
|
|
|
59
85
|
*
|
|
60
86
|
* ```svelte
|
|
61
87
|
* <DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
|
|
88
|
+
*
|
|
89
|
+
* <DateRangePicker
|
|
90
|
+
* mode="date"
|
|
91
|
+
* presets={[allTime("All time"), thisMonth("This month"), lastMonth("Last month")]}
|
|
92
|
+
* onchange={(r) => load(r)}
|
|
93
|
+
* />
|
|
62
94
|
* ```
|
|
63
95
|
*/
|
|
64
96
|
declare const DateRangePicker: import("svelte").Component<Props, {}, "value">;
|
|
@@ -1,30 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The pair a range picker emits, and the whole of what it emits.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* produces. A string rather than a `Date`
|
|
6
|
-
* body, a query string, a store, a load
|
|
7
|
-
* exactly this string anyway, while an ISO
|
|
8
|
-
* sorts correctly with a plain `<`. UTC rather
|
|
9
|
-
* different zones must be able to name the same window
|
|
4
|
+
* In the default `"datetime"` mode both ends are ISO-8601 instants in UTC
|
|
5
|
+
* (`2026-08-29T18:04:05.123Z`), the form `toISOString()` produces. A string rather than a `Date`
|
|
6
|
+
* because this value's job is to travel: into a fetch body, a query string, a store, a load
|
|
7
|
+
* function's return — all of which serialise a `Date` to exactly this string anyway, while an ISO
|
|
8
|
+
* string additionally survives the round trip back and sorts correctly with a plain `<`. UTC rather
|
|
9
|
+
* than a local offset because two clients in different zones must be able to name the same window
|
|
10
|
+
* with the same characters.
|
|
11
|
+
*
|
|
12
|
+
* In `"date"` mode both ends are calendar days, `2026-08-29` — the value of an
|
|
13
|
+
* `<input type="date">`, with no time and no zone, because a day is what the reader picked. An
|
|
14
|
+
* empty string leaves that side open: `{ from: "2026-03-01", to: "" }` is "since March", and two
|
|
15
|
+
* empty ends are all time.
|
|
10
16
|
*/
|
|
11
17
|
export interface DateRange {
|
|
12
18
|
from: string;
|
|
13
19
|
to: string;
|
|
14
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* What the picker works in: `"datetime"` for a window of instants (a dashboard's last 24 hours),
|
|
23
|
+
* `"date"` for a span of calendar days (a history filtered to last month).
|
|
24
|
+
*/
|
|
25
|
+
export type DateRangeMode = "datetime" | "date";
|
|
26
|
+
interface PresetBase {
|
|
27
|
+
/** Stable across renders — it is what the picker's selection prop holds. */
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
15
31
|
/**
|
|
16
32
|
* One relative window offered as a button — a fixed duration measured back from "now".
|
|
17
33
|
*
|
|
18
34
|
* `durationMs` is a duration and not a calendar step on purpose: "30d" means 30×24h, so the
|
|
19
35
|
* window keeps its length across a DST boundary instead of silently growing or shrinking an hour
|
|
20
|
-
* twice a year. A
|
|
36
|
+
* twice a year. A window that follows the calendar — this month, last month — is a
|
|
37
|
+
* {@link CalendarPreset}.
|
|
38
|
+
*
|
|
39
|
+
* In `"date"` mode the window becomes the days it touches: from the day `durationMs` ago to today.
|
|
21
40
|
*/
|
|
22
|
-
export interface
|
|
23
|
-
/** Stable across renders — it is what the picker's selection prop holds. */
|
|
24
|
-
id: string;
|
|
25
|
-
label: string;
|
|
41
|
+
export interface DurationPreset extends PresetBase {
|
|
26
42
|
durationMs: number;
|
|
43
|
+
range?: never;
|
|
27
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One window offered as a button and computed from the current moment — for the windows a
|
|
47
|
+
* duration cannot name: this month, last month, the last six months, all time. `range` returns the
|
|
48
|
+
* pair in the picker's mode, and runs again on every re-anchor, so "this month" turns into the
|
|
49
|
+
* next one when the month does.
|
|
50
|
+
*
|
|
51
|
+
* {@link thisMonth}, {@link lastMonth}, {@link lastMonths} and {@link allTime} build the usual
|
|
52
|
+
* ones for `"date"` mode.
|
|
53
|
+
*/
|
|
54
|
+
export interface CalendarPreset extends PresetBase {
|
|
55
|
+
range: (now: Date) => DateRange;
|
|
56
|
+
durationMs?: never;
|
|
57
|
+
}
|
|
58
|
+
export type DateRangePreset = DurationPreset | CalendarPreset;
|
|
28
59
|
/**
|
|
29
60
|
* The selection value that means "the user typed the two ends themselves".
|
|
30
61
|
*
|
|
@@ -33,8 +64,10 @@ export interface DateRangePreset {
|
|
|
33
64
|
export declare const CUSTOM_PRESET_ID = "custom";
|
|
34
65
|
/** The four windows a dashboard actually gets asked for. Replace the list, not the component. */
|
|
35
66
|
export declare const DEFAULT_PRESETS: readonly DateRangePreset[];
|
|
36
|
-
/**
|
|
37
|
-
export declare const
|
|
67
|
+
/** A local calendar day as `YYYY-MM-DD`, the value an `<input type="date">` holds. */
|
|
68
|
+
export declare const toDay: (date: Date) => string;
|
|
69
|
+
/** Builds the window a preset names as of `now`, in the picker's `mode`. */
|
|
70
|
+
export declare const buildRange: (preset: DateRangePreset, now?: Date | number, mode?: DateRangeMode) => DateRange;
|
|
38
71
|
/**
|
|
39
72
|
* Resolves a selection into the window it stands for right now, or `null` when there is nothing
|
|
40
73
|
* to build — an unknown id, or {@link CUSTOM_PRESET_ID}.
|
|
@@ -43,7 +76,41 @@ export declare const buildRange: (preset: DateRangePreset, now?: Date | number)
|
|
|
43
76
|
* range is never re-anchored" a property of the data rather than of the timer's wiring: even a
|
|
44
77
|
* tick that somehow fired while the user was in custom mode would have nothing to emit.
|
|
45
78
|
*/
|
|
46
|
-
export declare const anchorSelection: (selection: string, presets: readonly DateRangePreset[], now?: Date | number) => DateRange | null;
|
|
79
|
+
export declare const anchorSelection: (selection: string, presets: readonly DateRangePreset[], now?: Date | number, mode?: DateRangeMode) => DateRange | null;
|
|
80
|
+
/**
|
|
81
|
+
* The selection a picker handed `value` (and no `preset`) should open on: the preset whose window
|
|
82
|
+
* right now is exactly that pair, or {@link CUSTOM_PRESET_ID}. A page that remounts its picker —
|
|
83
|
+
* one per tab, say — gets its "Last month" button back instead of a custom pair spelling it out.
|
|
84
|
+
*
|
|
85
|
+
* A duration preset in `"datetime"` mode only matches to the millisecond, so in practice a seeded
|
|
86
|
+
* instant pair still opens as custom, as it always did.
|
|
87
|
+
*/
|
|
88
|
+
export declare const selectionFor: (value: DateRange, presets: readonly DateRangePreset[], now?: Date | number, mode?: DateRangeMode) => string;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a pair of days is in order. An empty end leaves that side open, so it can never put the
|
|
91
|
+
* pair out of order.
|
|
92
|
+
*/
|
|
93
|
+
export declare const isValidDayRange: (from: string, to: string) => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Reads an `<input type="date">` value back: the day itself, `""` for an empty field — no bound on
|
|
96
|
+
* that side — or `null` for anything that is not yet a real day.
|
|
97
|
+
*
|
|
98
|
+
* A year below 1000 counts as not yet a day. Chrome reports a value on every digit of the year as
|
|
99
|
+
* it is typed — `0002-03-01`, `0020-03-01`, `0202-03-01` — and each of those would otherwise be
|
|
100
|
+
* emitted, and fetched, on the way to `2026`.
|
|
101
|
+
*/
|
|
102
|
+
export declare const parseDay: (value: string | null | undefined) => string | null;
|
|
103
|
+
/** "This month", for `"date"` mode: from the 1st, open at the end. */
|
|
104
|
+
export declare const thisMonth: (label: string, id?: string) => CalendarPreset;
|
|
105
|
+
/** "Last month", for `"date"` mode: its first day to its last. */
|
|
106
|
+
export declare const lastMonth: (label: string, id?: string) => CalendarPreset;
|
|
107
|
+
/**
|
|
108
|
+
* The last `months` months, for `"date"` mode: from the same day that many months ago — or the
|
|
109
|
+
* last day that month has — open at the end.
|
|
110
|
+
*/
|
|
111
|
+
export declare const lastMonths: (months: number, label: string, id?: string) => CalendarPreset;
|
|
112
|
+
/** "All time", for `"date"` mode: both ends open. */
|
|
113
|
+
export declare const allTime: (label: string, id?: string) => CalendarPreset;
|
|
47
114
|
/** Whether the pair is in order. An empty end is not a range, so it is not valid either. */
|
|
48
115
|
export declare const isValidRange: (from: string | null, to: string | null) => boolean;
|
|
49
116
|
/**
|
|
@@ -99,3 +166,4 @@ export declare const createReAnchor: (options: {
|
|
|
99
166
|
intervalMs: number;
|
|
100
167
|
onAnchor: () => void;
|
|
101
168
|
}) => ReAnchor;
|
|
169
|
+
export {};
|
|
@@ -14,9 +14,16 @@ export const DEFAULT_PRESETS = [
|
|
|
14
14
|
{ id: "7d", label: "7d", durationMs: 7 * DAY_MS },
|
|
15
15
|
{ id: "30d", label: "30d", durationMs: 30 * DAY_MS },
|
|
16
16
|
];
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const pad = (value, length = 2) => String(value).padStart(length, "0");
|
|
18
|
+
/** A local calendar day as `YYYY-MM-DD`, the value an `<input type="date">` holds. */
|
|
19
|
+
export const toDay = (date) => `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
20
|
+
/** Builds the window a preset names as of `now`, in the picker's `mode`. */
|
|
21
|
+
export const buildRange = (preset, now = Date.now(), mode = "datetime") => {
|
|
19
22
|
const end = now instanceof Date ? now.getTime() : now;
|
|
23
|
+
if (preset.range)
|
|
24
|
+
return preset.range(new Date(end));
|
|
25
|
+
if (mode === "date")
|
|
26
|
+
return { from: toDay(new Date(end - preset.durationMs)), to: toDay(new Date(end)) };
|
|
20
27
|
return { from: new Date(end - preset.durationMs).toISOString(), to: new Date(end).toISOString() };
|
|
21
28
|
};
|
|
22
29
|
/**
|
|
@@ -27,12 +34,87 @@ export const buildRange = (preset, now = Date.now()) => {
|
|
|
27
34
|
* range is never re-anchored" a property of the data rather than of the timer's wiring: even a
|
|
28
35
|
* tick that somehow fired while the user was in custom mode would have nothing to emit.
|
|
29
36
|
*/
|
|
30
|
-
export const anchorSelection = (selection, presets, now = Date.now()) => {
|
|
37
|
+
export const anchorSelection = (selection, presets, now = Date.now(), mode = "datetime") => {
|
|
31
38
|
if (selection === CUSTOM_PRESET_ID)
|
|
32
39
|
return null;
|
|
33
40
|
const preset = presets.find(({ id }) => id === selection);
|
|
34
|
-
return preset ? buildRange(preset, now) : null;
|
|
41
|
+
return preset ? buildRange(preset, now, mode) : null;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The selection a picker handed `value` (and no `preset`) should open on: the preset whose window
|
|
45
|
+
* right now is exactly that pair, or {@link CUSTOM_PRESET_ID}. A page that remounts its picker —
|
|
46
|
+
* one per tab, say — gets its "Last month" button back instead of a custom pair spelling it out.
|
|
47
|
+
*
|
|
48
|
+
* A duration preset in `"datetime"` mode only matches to the millisecond, so in practice a seeded
|
|
49
|
+
* instant pair still opens as custom, as it always did.
|
|
50
|
+
*/
|
|
51
|
+
export const selectionFor = (value, presets, now = Date.now(), mode = "datetime") => {
|
|
52
|
+
const match = presets.find((preset) => {
|
|
53
|
+
const range = buildRange(preset, now, mode);
|
|
54
|
+
return range.from === value.from && range.to === value.to;
|
|
55
|
+
});
|
|
56
|
+
return match?.id ?? CUSTOM_PRESET_ID;
|
|
35
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Whether a pair of days is in order. An empty end leaves that side open, so it can never put the
|
|
60
|
+
* pair out of order.
|
|
61
|
+
*/
|
|
62
|
+
export const isValidDayRange = (from, to) => !from || !to || from <= to;
|
|
63
|
+
const DAY_PATTERN = /^([1-9]\d{3})-(\d{2})-(\d{2})$/;
|
|
64
|
+
/**
|
|
65
|
+
* Reads an `<input type="date">` value back: the day itself, `""` for an empty field — no bound on
|
|
66
|
+
* that side — or `null` for anything that is not yet a real day.
|
|
67
|
+
*
|
|
68
|
+
* A year below 1000 counts as not yet a day. Chrome reports a value on every digit of the year as
|
|
69
|
+
* it is typed — `0002-03-01`, `0020-03-01`, `0202-03-01` — and each of those would otherwise be
|
|
70
|
+
* emitted, and fetched, on the way to `2026`.
|
|
71
|
+
*/
|
|
72
|
+
export const parseDay = (value) => {
|
|
73
|
+
if (!value)
|
|
74
|
+
return "";
|
|
75
|
+
const match = DAY_PATTERN.exec(value);
|
|
76
|
+
if (!match)
|
|
77
|
+
return null;
|
|
78
|
+
const [, year, month, day] = match;
|
|
79
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
80
|
+
return date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? value : null;
|
|
81
|
+
};
|
|
82
|
+
/** `now` moved back `months` calendar months, kept on its day or the last one the month has. */
|
|
83
|
+
const monthsBefore = (now, months) => {
|
|
84
|
+
const month = now.getMonth() - months;
|
|
85
|
+
const lastDay = new Date(now.getFullYear(), month + 1, 0).getDate();
|
|
86
|
+
return new Date(now.getFullYear(), month, Math.min(now.getDate(), lastDay));
|
|
87
|
+
};
|
|
88
|
+
/** "This month", for `"date"` mode: from the 1st, open at the end. */
|
|
89
|
+
export const thisMonth = (label, id = "this-month") => ({
|
|
90
|
+
id,
|
|
91
|
+
label,
|
|
92
|
+
range: (now) => ({ from: toDay(new Date(now.getFullYear(), now.getMonth(), 1)), to: "" }),
|
|
93
|
+
});
|
|
94
|
+
/** "Last month", for `"date"` mode: its first day to its last. */
|
|
95
|
+
export const lastMonth = (label, id = "last-month") => ({
|
|
96
|
+
id,
|
|
97
|
+
label,
|
|
98
|
+
range: (now) => ({
|
|
99
|
+
from: toDay(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
|
|
100
|
+
to: toDay(new Date(now.getFullYear(), now.getMonth(), 0)),
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* The last `months` months, for `"date"` mode: from the same day that many months ago — or the
|
|
105
|
+
* last day that month has — open at the end.
|
|
106
|
+
*/
|
|
107
|
+
export const lastMonths = (months, label, id = `last-${months}-months`) => ({
|
|
108
|
+
id,
|
|
109
|
+
label,
|
|
110
|
+
range: (now) => ({ from: toDay(monthsBefore(now, months)), to: "" }),
|
|
111
|
+
});
|
|
112
|
+
/** "All time", for `"date"` mode: both ends open. */
|
|
113
|
+
export const allTime = (label, id = "all-time") => ({
|
|
114
|
+
id,
|
|
115
|
+
label,
|
|
116
|
+
range: () => ({ from: "", to: "" }),
|
|
117
|
+
});
|
|
36
118
|
/** Whether the pair is in order. An empty end is not a range, so it is not valid either. */
|
|
37
119
|
export const isValidRange = (from, to) => {
|
|
38
120
|
if (from === null || to === null)
|
|
@@ -41,7 +123,6 @@ export const isValidRange = (from, to) => {
|
|
|
41
123
|
const end = Date.parse(to);
|
|
42
124
|
return !Number.isNaN(start) && !Number.isNaN(end) && start <= end;
|
|
43
125
|
};
|
|
44
|
-
const pad = (value, length = 2) => String(value).padStart(length, "0");
|
|
45
126
|
const LOCAL_INPUT_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
46
127
|
/**
|
|
47
128
|
* Formats a UTC instant for the `value` of an `<input type="datetime-local">`, whose contents are
|
|
@@ -24,7 +24,7 @@ export { default as Skeleton } from "./Skeleton.svelte";
|
|
|
24
24
|
export { default as Tabs } from "./Tabs.svelte";
|
|
25
25
|
export { default as Tooltip } from "./Tooltip.svelte";
|
|
26
26
|
export { PICK_CLIP_PATH, PICK_PATH } from "./pick.js";
|
|
27
|
-
export { buildRange, CUSTOM_PRESET_ID, DEFAULT_PRESETS, isoToLocalInput, localInputToIso, type DateRange, type DateRangePreset, } from "./date-range.js";
|
|
27
|
+
export { allTime, buildRange, CUSTOM_PRESET_ID, DEFAULT_PRESETS, isoToLocalInput, lastMonth, lastMonths, localInputToIso, thisMonth, toDay, type CalendarPreset, type DateRange, type DateRangeMode, type DateRangePreset, type DurationPreset, } from "./date-range.js";
|
|
28
28
|
export { matchesSelectQuery, normalizeForSearch, type SelectOption } from "./select.js";
|
|
29
29
|
export { formatJsonText, parseJsonText, type JsonTextOptions, type JsonTextResult, } from "./json-text.js";
|
|
30
30
|
export { DEFAULT_JSON_VIEW_LABELS, type JsonViewLabels } from "./json-view.js";
|
package/dist/components/index.js
CHANGED
|
@@ -28,12 +28,12 @@ export { default as Tooltip } from "./Tooltip.svelte";
|
|
|
28
28
|
// copying the curve out of the library, which is how two versions of one shape start existing.
|
|
29
29
|
// Not for avatars: a profile picture cut to anything but a disc reads as a badge.
|
|
30
30
|
export { PICK_CLIP_PATH, PICK_PATH } from "./pick.js";
|
|
31
|
-
// The half of `date-range.js` a consumer of the picker has a use for: the
|
|
32
|
-
// preset
|
|
33
|
-
// pairing their own field with a `DateRange` has to repeat otherwise. The rest -- the selection
|
|
31
|
+
// The half of `date-range.js` a consumer of the picker has a use for: the prop types, the preset
|
|
32
|
+
// list, the calendar preset builders and the reserved id they are written against, and the
|
|
33
|
+
// conversions anyone pairing their own field with a `DateRange` has to repeat otherwise. The rest -- the selection
|
|
34
34
|
// anchor, the pair validator and the re-anchor clock -- is how the picker drives itself, and the
|
|
35
35
|
// picker never emits a pair that would fail the validator.
|
|
36
|
-
export { buildRange, CUSTOM_PRESET_ID, DEFAULT_PRESETS, isoToLocalInput, localInputToIso, } from "./date-range.js";
|
|
36
|
+
export { allTime, buildRange, CUSTOM_PRESET_ID, DEFAULT_PRESETS, isoToLocalInput, lastMonth, lastMonths, localInputToIso, thisMonth, toDay, } from "./date-range.js";
|
|
37
37
|
// The half of `select.ts` a consumer has a use for: the option type every caller of either
|
|
38
38
|
// control has to name, and the default matcher, so widening the search -- matching the value too,
|
|
39
39
|
// a synonym list -- can compose with the accent folding instead of reimplementing it. The
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* no portable way to import its own manifest, and a consumer that only wants to log a version
|
|
6
6
|
* should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
|
|
7
7
|
*/
|
|
8
|
-
export declare const LIBRARY_VERSION = "0.
|
|
8
|
+
export declare const LIBRARY_VERSION = "0.7.0";
|
|
9
9
|
export { AsyncAction, type AsyncActionStatus, type AsyncFn } from "./async-action.svelte.js";
|
|
10
10
|
export { reportError, setErrorReporter, type ErrorReporter } from "./error.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* no portable way to import its own manifest, and a consumer that only wants to log a version
|
|
6
6
|
* should not have to bundle one. `version.spec.ts` fails when this drifts from the manifest.
|
|
7
7
|
*/
|
|
8
|
-
export const LIBRARY_VERSION = "0.
|
|
8
|
+
export const LIBRARY_VERSION = "0.7.0";
|
|
9
9
|
// The root entry carries only what is not tied to one area: the error channel an app installs
|
|
10
10
|
// once, and the async state machine the components are built on. Everything visual is reached
|
|
11
11
|
// through a subpath — `/theme`, `/attachments`, `/components`, `/toast` — so importing a button
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viniaraujo68/plinth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A personal Svelte 5 design system: theme, application shell, interface primitives, routing metadata, a fetch wrapper and locale-aware formatters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|