@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 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
- * The selected window, as UTC ISO instants. Bindable, and also reported through
21
- * {@link Props.onchange} — a consumer needs one or the other, never both.
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, and only alongside `preset="custom"`.
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 first preset, or
31
- * to custom when `value` was seeded without naming a preset.
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(() => preset ?? (value ? CUSTOM_PRESET_ID : (presets[0]?.id ?? CUSTOM_PRESET_ID))),
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
- customFrom = isoToLocalInput(range?.from) ?? "";
104
- customTo = isoToLocalInput(range?.to) ?? "";
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 from = localInputToIso(customFrom);
121
- const to = localInputToIso(customTo);
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
- if (from === null || to === null) return null;
195
+ const pair = typedPair();
196
+ if (pair === undefined) return null;
137
197
 
138
- return isValidRange(from, to) ? null : invalidRangeLabel;
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: relative presets, a custom pair of local
179
- date-times, and `{ from, to }` as UTC ISO strings on the way out. It fetches nothing, knows no
180
- query parameter, and reads no URL — what the pair means is the consumer's business.
181
-
182
- A relative preset re-anchors on a timer (`reAnchorMs`, 60s by default, `0` to disable), so a
183
- dashboard left open keeps sliding forward. The timer suspends while the tab is hidden and fires
184
- once on return. A custom range is never touched by it.
185
-
186
- The two custom fields are `<input type="datetime-local">`, which means they are wall-clock time in
187
- the viewer's own zone with no offset attached, while the emitted pair stays in UTC. The conversion
188
- runs to whole minutes in both directions; see `isoToLocalInput` / `localInputToIso`.
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
- <div class="join" role="group" aria-label={label}>
202
- {#each presets as option (option.id)}
203
- <button
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="flex flex-col gap-1">
282
+ <div class={fieldWrapper}>
228
283
  <label class="label text-xs" for={fromId}>{fromLabel}</label>
229
284
  <input
230
285
  id={fromId}
231
- class="input min-h-11"
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="flex flex-col gap-1">
300
+ <div class={fieldWrapper}>
245
301
  <label class="label text-xs" for={toId}>{toLabel}</label>
246
302
  <input
247
303
  id={toId}
248
- class="input min-h-11"
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
- * The selected window, as UTC ISO instants. Bindable, and also reported through
7
- * {@link Props.onchange} — a consumer needs one or the other, never both.
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, and only alongside `preset="custom"`.
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 first preset, or
17
- * to custom when `value` was seeded without naming a preset.
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: relative presets, a custom pair of local
44
- * date-times, and `{ from, to }` as UTC ISO strings on the way out. It fetches nothing, knows no
45
- * query parameter, and reads no URL — what the pair means is the consumer's business.
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
- * A relative preset re-anchors on a timer (`reAnchorMs`, 60s by default, `0` to disable), so a
48
- * dashboard left open keeps sliding forward. The timer suspends while the tab is hidden and fires
49
- * once on return. A custom range is never touched by it.
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
- * The two custom fields are `<input type="datetime-local">`, which means they are wall-clock time in
52
- * the viewer's own zone with no offset attached, while the emitted pair stays in UTC. The conversion
53
- * runs to whole minutes in both directions; see `isoToLocalInput` / `localInputToIso`.
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
- * Both ends are ISO-8601 instants in UTC (`2026-08-29T18:04:05.123Z`), the form `toISOString()`
5
- * produces. A string rather than a `Date` because this value's job is to travel: into a fetch
6
- * body, a query string, a store, a load function's return — all of which serialise a `Date` to
7
- * exactly this string anyway, while an ISO string additionally survives the round trip back and
8
- * sorts correctly with a plain `<`. UTC rather than a local offset because two clients in
9
- * different zones must be able to name the same window with the same characters.
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 caller who really wants "this month" wants a custom range, not a preset.
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 DateRangePreset {
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
- /** Builds the window a preset names, ending at `now`. */
37
- export declare const buildRange: (preset: DateRangePreset, now?: Date | number) => DateRange;
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
- /** Builds the window a preset names, ending at `now`. */
18
- export const buildRange = (preset, now = Date.now()) => {
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";
@@ -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 two prop types, the
32
- // preset list and the reserved id they are written against, and the three conversions anyone
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.5.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.5.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.5.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",