@viniaraujo68/plinth 0.5.0 → 0.6.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,14 @@ 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
+
318
326
  `JsonInput` edits a JSON document as text: it reports through a bindable `invalid` whether the text
319
327
  parses, shows the parser's message after the first blur, can reindent on blur, and sets the
320
328
  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
  /**
@@ -56,6 +68,7 @@
56
68
 
57
69
  let {
58
70
  class: cls,
71
+ mode = "datetime",
59
72
  value = $bindable(),
60
73
  presets = DEFAULT_PRESETS,
61
74
  preset,
@@ -76,7 +89,13 @@
76
89
  // The selection is the picker's own state rather than a bound prop: the pair is the entire
77
90
  // output, and a second bound value would be a second thing for a consumer to keep in step.
78
91
  let selection = $state(
79
- untrack(() => preset ?? (value ? CUSTOM_PRESET_ID : (presets[0]?.id ?? CUSTOM_PRESET_ID))),
92
+ untrack(
93
+ () =>
94
+ preset ??
95
+ (value
96
+ ? selectionFor(value, presets, Date.now(), mode)
97
+ : (presets[0]?.id ?? CUSTOM_PRESET_ID)),
98
+ ),
80
99
  );
81
100
 
82
101
  // Wall-clock text for the two fields. Empty until custom mode is entered, because reading the
@@ -86,6 +105,12 @@
86
105
  let customTo = $state("");
87
106
 
88
107
  const isCustom = $derived(selection === CUSTOM_PRESET_ID);
108
+ const days = $derived(mode === "date");
109
+
110
+ const options = $derived([
111
+ ...presets.map(({ id, label }) => ({ id, label })),
112
+ { id: CUSTOM_PRESET_ID, label: customLabel },
113
+ ]);
89
114
 
90
115
  const emit = (next: DateRange) => {
91
116
  if (value?.from === next.from && value?.to === next.to) return;
@@ -95,17 +120,37 @@
95
120
  };
96
121
 
97
122
  const anchor = () => {
98
- const next = anchorSelection(selection, presets, Date.now());
123
+ const next = anchorSelection(selection, presets, Date.now(), mode);
99
124
  if (next) emit(next);
100
125
  };
101
126
 
102
127
  const seedCustomFields = (range: DateRange | undefined) => {
103
- customFrom = isoToLocalInput(range?.from) ?? "";
104
- customTo = isoToLocalInput(range?.to) ?? "";
128
+ const toField = days ? parseDay : isoToLocalInput;
129
+ customFrom = toField(range?.from) ?? "";
130
+ customTo = toField(range?.to) ?? "";
131
+ };
132
+
133
+ /**
134
+ * The typed pair, read back: `undefined` while either field is not yet a value — half typed —
135
+ * and otherwise the pair with whether it is in order. In `"date"` mode an empty field is a value,
136
+ * an open end, where in `"datetime"` mode it is not yet one.
137
+ */
138
+ const typedPair = (): { range: DateRange; ordered: boolean } | undefined => {
139
+ if (days) {
140
+ const from = parseDay(customFrom);
141
+ const to = parseDay(customTo);
142
+ if (from === null || to === null) return undefined;
143
+ return { range: { from, to }, ordered: isValidDayRange(from, to) };
144
+ }
145
+
146
+ const from = localInputToIso(customFrom);
147
+ const to = localInputToIso(customTo);
148
+ if (from === null || to === null) return undefined;
149
+ return { range: { from, to }, ordered: isValidRange(from, to) };
105
150
  };
106
151
 
107
152
  const enterCustom = () => {
108
- const current = value ?? (presets[0] ? buildRange(presets[0]) : undefined);
153
+ const current = value ?? (presets[0] ? buildRange(presets[0], Date.now(), mode) : undefined);
109
154
 
110
155
  seedCustomFields(current);
111
156
  selection = CUSTOM_PRESET_ID;
@@ -117,27 +162,26 @@
117
162
  };
118
163
 
119
164
  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 });
165
+ const pair = typedPair();
166
+ if (pair?.ordered) emit(pair.range);
126
167
  };
127
168
 
128
169
  const rangeError = $derived.by(() => {
129
170
  if (!isCustom) return null;
130
171
 
131
- const from = localInputToIso(customFrom);
132
- const to = localInputToIso(customTo);
133
-
134
172
  // A half-typed field is not yet wrong, so it says nothing rather than flashing an error at
135
173
  // someone who is still filling the form in.
136
- if (from === null || to === null) return null;
174
+ const pair = typedPair();
175
+ if (pair === undefined) return null;
137
176
 
138
- return isValidRange(from, to) ? null : invalidRangeLabel;
177
+ return pair.ordered ? null : invalidRangeLabel;
139
178
  });
140
179
 
180
+ const pick = (id: string) => {
181
+ if (id === CUSTOM_PRESET_ID) enterCustom();
182
+ else selection = id;
183
+ };
184
+
141
185
  $effect(() => {
142
186
  const intervalMs = reAnchorMs;
143
187
  if (selection === CUSTOM_PRESET_ID) return;
@@ -175,17 +219,21 @@
175
219
 
176
220
  <!--
177
221
  @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`.
222
+ A from/to picker built as a plain form control: presets, a custom pair the reader types, and
223
+ `{ from, to }` on the way out. It fetches nothing, knows no query parameter, and reads no URL — what
224
+ the pair means is the consumer's business.
225
+
226
+ It works in one of two modes. `"datetime"`, the default, is a window of instants: the custom
227
+ fields are `<input type="datetime-local">`, wall-clock time in the viewer's own zone, while the
228
+ emitted pair stays in UTC — the conversion runs to whole minutes in both directions; see
229
+ `isoToLocalInput` / `localInputToIso`. `"date"` is a span of calendar days: the fields are
230
+ `<input type="date">`, the pair is `YYYY-MM-DD`, and an empty end leaves that side open.
231
+
232
+ A preset is a duration back from now ("24h") or a window computed from the calendar ("this month",
233
+ built with `thisMonth`, `lastMonth`, `lastMonths` and `allTime`). Either re-anchors on a timer
234
+ (`reAnchorMs`, 60s by default, `0` to disable), so a dashboard left open keeps sliding forward and
235
+ "this month" turns into the next one. The timer suspends while the tab is hidden and fires once on
236
+ return. A custom range is never touched by it.
189
237
 
190
238
  Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
191
239
  `toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
@@ -194,33 +242,19 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
194
242
 
195
243
  ```svelte
196
244
  <DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
245
+
246
+ <DateRangePicker
247
+ mode="date"
248
+ presets={[allTime("All time"), thisMonth("This month"), lastMonth("Last month")]}
249
+ onchange={(r) => load(r)}
250
+ />
197
251
  ```
198
252
  -->
199
253
 
200
254
  <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>
255
+ <!-- A segmented control rather than a joined row: a calendar picker easily offers six windows,
256
+ and a joined row cannot wrap onto a second line at phone width. -->
257
+ <SegmentedControl {options} value={selection} {label} onchange={pick} />
224
258
 
225
259
  {#if isCustom}
226
260
  <div class="flex flex-wrap items-end gap-x-4 gap-y-3">
@@ -230,8 +264,9 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
230
264
  id={fromId}
231
265
  class="input min-h-11"
232
266
  class:input-error={rangeError !== null}
233
- type="datetime-local"
267
+ type={days ? "date" : "datetime-local"}
234
268
  value={customFrom}
269
+ max={days && customTo ? customTo : undefined}
235
270
  aria-invalid={rangeError !== null}
236
271
  aria-describedby={rangeError === null ? undefined : errorId}
237
272
  oninput={({ currentTarget }) => {
@@ -247,8 +282,9 @@ buttons are labelled by the list itself, through `DateRangePreset.label`.
247
282
  id={toId}
248
283
  class="input min-h-11"
249
284
  class:input-error={rangeError !== null}
250
- type="datetime-local"
285
+ type={days ? "date" : "datetime-local"}
251
286
  value={customTo}
287
+ min={days && customFrom ? customFrom : undefined}
252
288
  aria-invalid={rangeError !== null}
253
289
  aria-describedby={rangeError === null ? undefined : errorId}
254
290
  oninput={({ currentTarget }) => {
@@ -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
  /**
@@ -40,17 +47,21 @@ interface Props {
40
47
  onchange?: (value: DateRange) => void;
41
48
  }
42
49
  /**
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.
50
+ * A from/to picker built as a plain form control: presets, a custom pair the reader types, and
51
+ * `{ from, to }` on the way out. It fetches nothing, knows no query parameter, and reads no URL — what
52
+ * the pair means is the consumer's business.
46
53
  *
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.
54
+ * It works in one of two modes. `"datetime"`, the default, is a window of instants: the custom
55
+ * fields are `<input type="datetime-local">`, wall-clock time in the viewer's own zone, while the
56
+ * emitted pair stays in UTC — the conversion runs to whole minutes in both directions; see
57
+ * `isoToLocalInput` / `localInputToIso`. `"date"` is a span of calendar days: the fields are
58
+ * `<input type="date">`, the pair is `YYYY-MM-DD`, and an empty end leaves that side open.
50
59
  *
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`.
60
+ * A preset is a duration back from now ("24h") or a window computed from the calendar ("this month",
61
+ * built with `thisMonth`, `lastMonth`, `lastMonths` and `allTime`). Either re-anchors on a timer
62
+ * (`reAnchorMs`, 60s by default, `0` to disable), so a dashboard left open keeps sliding forward and
63
+ * "this month" turns into the next one. The timer suspends while the tab is hidden and fires once on
64
+ * return. A custom range is never touched by it.
54
65
  *
55
66
  * Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
56
67
  * `toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
@@ -59,6 +70,12 @@ interface Props {
59
70
  *
60
71
  * ```svelte
61
72
  * <DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
73
+ *
74
+ * <DateRangePicker
75
+ * mode="date"
76
+ * presets={[allTime("All time"), thisMonth("This month"), lastMonth("Last month")]}
77
+ * onchange={(r) => load(r)}
78
+ * />
62
79
  * ```
63
80
  */
64
81
  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.6.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.6.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.6.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",