@viniaraujo68/plinth 0.3.1 → 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.
Files changed (107) hide show
  1. package/README.md +197 -19
  2. package/dist/components/DateRangePicker.svelte +93 -57
  3. package/dist/components/DateRangePicker.svelte.d.ts +32 -15
  4. package/dist/components/ErrorDisplay.svelte +30 -16
  5. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  6. package/dist/components/JsonView.svelte +43 -0
  7. package/dist/components/JsonView.svelte.d.ts +4 -0
  8. package/dist/components/JsonViewFrame.svelte +654 -0
  9. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  10. package/dist/components/Paginator.svelte +34 -8
  11. package/dist/components/Paginator.svelte.d.ts +10 -0
  12. package/dist/components/RequestErrorDisplay.svelte +43 -0
  13. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  14. package/dist/components/Tabs.svelte +204 -0
  15. package/dist/components/Tabs.svelte.d.ts +76 -0
  16. package/dist/components/VirtualJsonView.svelte +67 -0
  17. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  18. package/dist/components/date-range.d.ts +82 -14
  19. package/dist/components/date-range.js +86 -5
  20. package/dist/components/index.d.ts +9 -2
  21. package/dist/components/index.js +15 -9
  22. package/dist/components/json-view.d.ts +70 -0
  23. package/dist/components/json-view.js +302 -0
  24. package/dist/components/request-error.d.ts +8 -0
  25. package/dist/components/request-error.js +55 -0
  26. package/dist/components/tabs.d.ts +5 -0
  27. package/dist/components/tabs.js +1 -0
  28. package/dist/http.d.ts +10 -5
  29. package/dist/http.js +34 -6
  30. package/dist/i18n/LocaleToggle.svelte +45 -0
  31. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  32. package/dist/i18n/i18n.svelte.d.ts +56 -0
  33. package/dist/i18n/i18n.svelte.js +94 -0
  34. package/dist/i18n/index.d.ts +3 -0
  35. package/dist/i18n/index.js +7 -0
  36. package/dist/i18n/translate.d.ts +30 -0
  37. package/dist/i18n/translate.js +38 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/redirect.d.ts +40 -0
  41. package/dist/redirect.js +59 -0
  42. package/dist/routing.svelte.d.ts +1 -0
  43. package/dist/routing.svelte.js +3 -0
  44. package/dist/shell/AppShell.svelte +265 -93
  45. package/dist/shell/AppShell.svelte.d.ts +3 -2
  46. package/dist/shell/NavigationProgress.svelte +85 -0
  47. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  48. package/dist/shell/TopBar.svelte +191 -0
  49. package/dist/shell/TopBar.svelte.d.ts +41 -0
  50. package/dist/shell/UserAvatar.svelte +50 -0
  51. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  52. package/dist/shell/index.d.ts +2 -0
  53. package/dist/shell/index.js +7 -3
  54. package/dist/shell/initials.d.ts +6 -0
  55. package/dist/shell/initials.js +13 -0
  56. package/dist/table/DataTable.svelte +199 -9
  57. package/dist/table/DataTable.svelte.d.ts +28 -5
  58. package/dist/table/FilterBar.svelte +417 -0
  59. package/dist/table/FilterBar.svelte.d.ts +34 -0
  60. package/dist/table/FilterRuleEditor.svelte +290 -0
  61. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  62. package/dist/table/filter-labels.d.ts +23 -0
  63. package/dist/table/filter-labels.js +30 -0
  64. package/dist/table/filter.d.ts +27 -0
  65. package/dist/table/filter.js +279 -0
  66. package/dist/table/index.d.ts +4 -1
  67. package/dist/table/index.js +5 -2
  68. package/dist/table/types.d.ts +49 -0
  69. package/dist/toast/Toaster.svelte +7 -3
  70. package/dist/toast/index.d.ts +1 -1
  71. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  72. package/dist/toast/toast-manager.svelte.js +61 -1
  73. package/dist/url-state/codec.d.ts +15 -0
  74. package/dist/url-state/codec.js +1 -0
  75. package/dist/url-state/index.d.ts +5 -0
  76. package/dist/url-state/index.js +4 -0
  77. package/dist/url-state/params-codec.d.ts +73 -0
  78. package/dist/url-state/params-codec.js +116 -0
  79. package/dist/url-state/search-params.d.ts +4 -0
  80. package/dist/url-state/search-params.js +28 -0
  81. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  82. package/dist/url-state/synced-state.svelte.js +88 -0
  83. package/dist/url-state/url-store.svelte.d.ts +12 -0
  84. package/dist/url-state/url-store.svelte.js +56 -0
  85. package/dist/virtual/DataList.svelte +215 -0
  86. package/dist/virtual/DataList.svelte.d.ts +52 -0
  87. package/dist/virtual/VirtualList.svelte +150 -0
  88. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  89. package/dist/virtual/index.d.ts +7 -0
  90. package/dist/virtual/index.js +6 -0
  91. package/dist/virtual/keyed-cache.d.ts +7 -0
  92. package/dist/virtual/keyed-cache.js +20 -0
  93. package/dist/virtual/labels.d.ts +12 -0
  94. package/dist/virtual/labels.js +6 -0
  95. package/dist/virtual/paging.d.ts +5 -0
  96. package/dist/virtual/paging.js +11 -0
  97. package/dist/virtual/range.d.ts +9 -0
  98. package/dist/virtual/range.js +31 -0
  99. package/dist/virtual/size-tree.d.ts +10 -0
  100. package/dist/virtual/size-tree.js +56 -0
  101. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  102. package/dist/virtual/virtualizer.svelte.js +295 -0
  103. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  104. package/dist/virtual/windowed-data.svelte.js +139 -0
  105. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  106. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  107. package/package.json +13 -1
package/README.md CHANGED
@@ -169,6 +169,16 @@ theme context. `AppShell` fills its parent, so give it one with a height. Its na
169
169
  titled first-level routes that have a page of their own; a titled route with no page stays a heading
170
170
  in the breadcrumb trail and is left out of the navigation, which has nothing to link it to.
171
171
 
172
+ One level of nesting is read off the same tree: a first-level route whose children are titled,
173
+ static and have pages of their own becomes a **group**, a header that folds those children away.
174
+ It opens by itself when the current page is inside it and then stays wherever the reader leaves it;
175
+ the group's own page, when it has one, leads the fold under `overviewLabel` ("Overview"). Children
176
+ go through the same role gate as everything else, and a group left with no children in scope falls
177
+ back to a plain entry — or vanishes, when it has no page either. In the collapsed rail a group is
178
+ its icon, and clicking it expands the sidebar onto the open group. On the bottom bar a group with a
179
+ page takes a slot and links there, one without takes none, and every group's children go into the
180
+ "More" sheet under its heading. Routes with no such children render exactly as before.
181
+
172
182
  An app that cannot render the controller in the right state on the server — a prerendered site, a
173
183
  client-only app — usually stamps `data-theme="plinth-light"` or `data-theme="plinth-dark"` on
174
184
  `<html>` from a head script, so the very first paint is already right. That stamp is a **seed for
@@ -251,36 +261,79 @@ arrangement across reloads. `createFormatters` decides the
251
261
  locale once, and `createHttpClient` takes the three things that actually differ between apps — the
252
262
  base URL, how auth is injected, and how the backend spells an error body.
253
263
 
264
+ Work the reader waits on goes through **`toast.promise`**: a spinning `loading` toast that turns into
265
+ a success or an error in place once the work settles. It never rejects — it resolves to the value,
266
+ or to `undefined` on failure — and hands the failure to `onerror`, or to `reportError` when none is
267
+ given, the same rule `AsyncButton` follows. `toast.update(id, patch)` changes a live toast in place.
268
+
269
+ ```ts
270
+ const saved = await toast.promise(api.post("/orders", draft), {
271
+ loading: "Saving…",
272
+ success: (order) => `Order ${order.id} saved.`,
273
+ error: (error) => errorMessage(error),
274
+ });
275
+ ```
276
+
277
+ A request that failed in the page rather than in a toast renders **`RequestErrorDisplay`**, which
278
+ sorts the error with **`classifyError`** — offline, unauthorized, forbidden, not found, server or
279
+ unknown — and gives each kind its own icon and wording, all of it overridable through `labels`. It
280
+ never rethrows, so an `{:catch error}` branch that renders it is safe on the server too.
281
+
254
282
  ## Entry points
255
283
 
256
- | Import | What it carries |
257
- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- |
258
- | `@viniaraujo68/plinth` | `AsyncAction`, the error reporter, `LIBRARY_VERSION` |
259
- | `@viniaraujo68/plinth/theme` | `ThemeContext`, `ThemeController`, `ThemeToggle`, the cookie helpers, `createCssColorReader` |
260
- | `@viniaraujo68/plinth/theme.css` | The stylesheet the tokens are declared in |
261
- | `@viniaraujo68/plinth/attachments` | The `tooltip` attachment, usable on any tag, and `sortable`, which reorders a list from its drag handles |
262
- | `@viniaraujo68/plinth/components` | The primitives, the `DateRange` and option-matching helpers |
263
- | `@viniaraujo68/plinth/confirm` | `confirm`, `Confirmer`, the challenge helpers |
264
- | `@viniaraujo68/plinth/shell` | `AppShell`, `Breadcrumbs` |
265
- | `@viniaraujo68/plinth/table` | `DataTable`, the sorting and column-layout functions behind it, `competitionRanks`, the sort ↔ URL helpers |
266
- | `@viniaraujo68/plinth/toast` | The toast queue and its host |
267
- | `@viniaraujo68/plinth/routing` | `RoutingConfig`, `RoutingContext`, `resolvePathname`, `withSearchParams` |
268
- | `@viniaraujo68/plinth/user` | `UserContext`, `ScopedComponent`, the anonymous default |
269
- | `@viniaraujo68/plinth/http` | `createHttpClient`, `ApiError`, `errorMessage`, `errorStatus` |
270
- | `@viniaraujo68/plinth/formatters` | `createFormatters`, over `Intl` |
284
+ | Import | What it carries |
285
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
286
+ | `@viniaraujo68/plinth` | `AsyncAction`, the error reporter, `LIBRARY_VERSION` |
287
+ | `@viniaraujo68/plinth/theme` | `ThemeContext`, `ThemeController`, `ThemeToggle`, the cookie helpers, `createCssColorReader` |
288
+ | `@viniaraujo68/plinth/theme.css` | The stylesheet the tokens are declared in |
289
+ | `@viniaraujo68/plinth/attachments` | The `tooltip` attachment, usable on any tag, and `sortable`, which reorders a list from its drag handles |
290
+ | `@viniaraujo68/plinth/components` | The primitives, the `DateRange` and option-matching helpers |
291
+ | `@viniaraujo68/plinth/confirm` | `confirm`, `Confirmer`, the challenge helpers |
292
+ | `@viniaraujo68/plinth/shell` | `AppShell`, `TopBar`, `Breadcrumbs`, `NavigationProgress` |
293
+ | `@viniaraujo68/plinth/table` | `DataTable`, the sorting and column-layout functions behind it, `competitionRanks`, the sort ↔ URL helpers |
294
+ | `@viniaraujo68/plinth/toast` | The toast queue, `toast.loading`, `toast.promise`, `toast.update`, and its host |
295
+ | `@viniaraujo68/plinth/routing` | `RoutingConfig`, `RoutingContext`, `resolvePathname`, `withSearchParams`, `safeNext`, `loginUrl`, `withNext` |
296
+ | `@viniaraujo68/plinth/user` | `UserContext`, `ScopedComponent`, the anonymous default |
297
+ | `@viniaraujo68/plinth/http` | `createHttpClient`, `ApiError`, `errorMessage`, `errorStatus`, `classifyError` |
298
+ | `@viniaraujo68/plinth/formatters` | `createFormatters`, over `Intl` |
299
+ | `@viniaraujo68/plinth/i18n` | `createI18n`, `LocaleToggle`, the interpolation and plural helpers |
300
+ | `@viniaraujo68/plinth/virtual` | `Virtualizer`, `VirtualList`, `WindowedData`, `WindowedVirtualizer`, `DataList` |
301
+ | `@viniaraujo68/plinth/url-state` | `createUrlState`, `urlStore`, `SyncedState`, `createParamsCodec` and `param`, the codec contracts |
271
302
 
272
303
  The components entry point ships `AsyncButton`, `Combobox`, `Copyable`, `DateRangePicker`,
273
- `Dialog`, `DragHandle`, `Dropdown`, `ErrorDisplay`, `ErrorPage`, `JsonInput`, `Listbox`, `LoadingButton`, `Logo`,
274
- `Modal`, `MultiSelect`, `Paginator`, `RefreshButton`, `SegmentedControl`, `Select`, `Skeleton` and
275
- `Tooltip`. Every one of them has a page in the showcase.
304
+ `Dialog`, `DragHandle`, `Dropdown`, `ErrorDisplay`, `ErrorPage`, `JsonInput`, `JsonView`, `Listbox`, `LoadingButton`, `Logo`,
305
+ `Modal`, `MultiSelect`, `Paginator`, `RefreshButton`, `RequestErrorDisplay`, `SegmentedControl`, `Select`, `Skeleton`, `Tabs`,
306
+ `VirtualJsonView`
307
+ and `Tooltip`. Every one of them has a page in the showcase.
276
308
  It also exports the mark's outline as data — `PICK_PATH` and `PICK_CLIP_PATH` — for anything
277
- that wants the pick as a silhouette of its own.
309
+ that wants the pick as a silhouette of its own. Not for avatars: the shell draws a profile picture as
310
+ a disc, and an app's should be one too.
311
+
312
+ **`Tabs`** switches what one panel shows: a `tablist` with a roving tabindex, where the arrow keys,
313
+ Home and End move the selection and the selection follows the focus. It scrolls sideways on a phone
314
+ instead of wrapping, fading at the edge while tabs are hidden past it. `value` is bindable; a page
315
+ that keeps its section in the URL passes it in and navigates on `onchange`, and `idPrefix` makes the
316
+ tab ids predictable so the panel can name its tab through `aria-labelledby`.
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.
278
325
 
279
326
  `JsonInput` edits a JSON document as text: it reports through a bindable `invalid` whether the text
280
327
  parses, shows the parser's message after the first blur, can reindent on blur, and sets the
281
328
  textarea's custom validity so a form will not submit a broken document. `parseJsonText` and
282
329
  `formatJsonText` are the same rules, for reading the text once it is sent.
283
330
 
331
+ `JsonView` shows a value read-only, as `JSON.stringify(value, null, 2)` would print it: `toJSON` is
332
+ honoured, so a `Date` shows as its ISO string, and a reference back to an ancestor shows a marker
333
+ instead of recursing. Nodes fold one by one, all at once or from `collapseDepth` down; the search
334
+ (Ctrl/Cmd+F while the viewer has the focus) reaches inside folds and unfolds what it lands on; and
335
+ copying — the whole document or a selection — writes the expanded text, never the fold summaries.
336
+
284
337
  Two of those pick one thing from a list, and which one to reach for is decided by the length of
285
338
  the list rather than by a flag:
286
339
 
@@ -337,6 +390,131 @@ Enter confirms only once it matches. The library ships no translations, so `conf
337
390
  `cancelLabel`, `closeLabel` and `challengeLabel` on the host translate every confirm at once, and
338
391
  any call overrides them through its own options.
339
392
 
393
+ ## Top bar, progress line and the sign-in round trip
394
+
395
+ `AppShell` is built for an app with a navigation to show. One with two or three pages and an
396
+ account puts **`TopBar`** over the page instead: the brand at the start, the app's controls at the
397
+ end, and — once the user context says someone is signed in — their avatar, opening an account menu
398
+ with their name, the app's own `<li>` entries and signing out. Choosing an entry closes the menu,
399
+ and `--plinth-top-bar-width` caps the bar's content to the width of the page under it.
400
+
401
+ ```svelte
402
+ <TopBar menuLabel={t("nav.accountMenu")} logoutLabel={t("nav.logout")}>
403
+ {#snippet brand()}<a href="/">Acme</a>{/snippet}
404
+ {#snippet actions()}
405
+ <LocaleToggle {i18n} label={t("nav.language")} />
406
+ <ThemeToggle iconOnly />
407
+ {/snippet}
408
+ {#snippet menu()}<li><a href="/account">{t("nav.account")}</a></li>{/snippet}
409
+ </TopBar>
410
+ ```
411
+
412
+ **`NavigationProgress`** goes with either shell: a line across the top of the window while a
413
+ navigation takes longer than `delay` (150ms). The library imports nothing from SvelteKit, so the app
414
+ says when one is under way — `<NavigationProgress active={navigating.to !== null} />`.
415
+
416
+ The routing entry point also carries the `?next=` round trip of a sign-in page. A guard sends a
417
+ visitor to **`loginUrl(page.url)`** — `/login?next=/where/they/were`, path only, since the query
418
+ string is where share tokens live. The sign-in page reads the parameter back through
419
+ **`safeNext`**, which keeps it only if the browser would resolve it to the same site:
420
+ `//evil.example`, `/\evil.example` and the spellings the URL parser turns into them come back
421
+ `null`, so a crafted link cannot send someone who just typed their password elsewhere.
422
+ **`withNext`** carries the same path across the link between sign-in and sign-up.
423
+
424
+ ## Languages
425
+
426
+ `@viniaraujo68/plinth/i18n` is for the app's own text — the library still ships no translations,
427
+ and its components keep taking every word through props. **`createI18n`** takes a flat dictionary
428
+ per locale and returns the current locale as `$state`, a `t()` that reads it, and `tag`, the BCP 47
429
+ tag for `Intl`:
430
+
431
+ ```ts
432
+ // src/lib/i18n.svelte.ts
433
+ import { createI18n } from "@viniaraujo68/plinth/i18n";
434
+ import pt from "./i18n/pt.js";
435
+ import en from "./i18n/en.js";
436
+
437
+ export const i18n = createI18n({
438
+ dictionaries: { pt, en },
439
+ tags: { pt: "pt-BR", en: "en-US" },
440
+ fallback: "pt",
441
+ unmatched: "en",
442
+ storageKey: "acme.locale",
443
+ });
444
+ export const { t } = i18n;
445
+ ```
446
+
447
+ `t("days", { count: 3 })` fills `{count}` and picks between a `{ one, other }` pair — exactly one
448
+ takes `one`, everything else, zero included, takes `other`, which is not what CLDR says for
449
+ Portuguese and is what its readers expect. A key missing from the current dictionary is read from the
450
+ fallback one. The first visit starts on the browser's language, matched by language when the region
451
+ differs, or on `unmatched` when nothing matches; a later one starts on the pick
452
+ **`LocaleToggle`** remembered. Switching relabels `<html lang>` too. The server renders the fallback
453
+ locale and never switches — the instance is shared by every request — and the browser corrects the
454
+ text during hydration.
455
+
456
+ ## URL state
457
+
458
+ `@viniaraujo68/plinth/url-state` keeps a screen's filters, sorting and paging in the query string,
459
+ so a link reopens what its sender was looking at and the back button undoes a search. It is state,
460
+ not a query layer: nothing in it fetches or caches.
461
+
462
+ ```svelte
463
+ <script lang="ts">
464
+ import { createParamsCodec, createUrlState, param } from "@viniaraujo68/plinth/url-state";
465
+
466
+ const catalog = createParamsCodec({
467
+ q: param.string({ default: "" }),
468
+ sort: param.string({ oneOf: ["asc", "desc"], default: "asc" }),
469
+ tags: param.list(param.string()),
470
+ page: param.number({ default: 1, min: 1, integer: true }),
471
+ });
472
+
473
+ const filters = createUrlState(catalog, { resetOnCommit: ["page"] });
474
+ </script>
475
+
476
+ <input bind:value={filters.draft.q} />
477
+ <button disabled={!filters.isDirty} onclick={filters.commit}>Apply</button>
478
+ <button onclick={() => filters.update((s) => ({ ...s, page: s.page + 1 }))}>Next</button>
479
+ ```
480
+
481
+ A form edits **`draft`**; **`commit()`** normalizes it into **`committed`** and writes the owned
482
+ params to the URL, leaving every other param alone. **`update(fn)`** commits one change eagerly —
483
+ a page turn — without dragging half-typed filters along; **`revert()`** drops the draft's edits and
484
+ **`reset()`** returns both to the defaults. **`resetOnCommit`** names the fields that go back to
485
+ their defaults whenever a commit changes anything else, which is what returns a new search to page 1.
486
+ A URL change that does not change **`key()`** — a foreign param, a reorder — is ignored, so it never
487
+ clobbers a draft; a real one, such as the back button, replaces both halves and calls `onSynced`.
488
+
489
+ The handlers never throw on a hand-edited URL: a value they cannot read falls back to the field's
490
+ default — `number` rejects `NaN` and empty values and honours `min`, `max` and `integer`, `boolean`
491
+ takes only `"true"` and `"false"`, `date` reads `YYYY-MM-DD` as local midnight and refuses
492
+ impossible days, and an invalid `Date` is never written. A value equal to its default stays out of
493
+ the URL. `list` reads a repeated param, and its default is always the empty list.
494
+
495
+ Every commit is a `goto` with focus and scroll kept, and pushes a history entry. **`replace`**
496
+ rewrites the current entry instead — `true` for search-as-you-type, or a function of the previous
497
+ and next owned params, where `changedParams` helps:
498
+ `replace: (prev, next) => changedParams(prev, next).every((name) => name === "q")`. Several states
499
+ on one page can commit in the same tick; the writes merge into one navigation.
500
+
501
+ Create it while a component initializes: it follows the URL through an `$effect`. Effects do not
502
+ run on the server, so a server-rendered or prerendered page renders the defaults and picks up the
503
+ URL once it hydrates. A page whose first paint has to reflect the query string reads it in its own
504
+ `load` and is not prerendered.
505
+
506
+ `key()` is what a fetch is keyed by. With TanStack Query it goes straight into the key:
507
+
508
+ ```ts
509
+ const books = createQuery(() => ({
510
+ queryKey: ["books", filters.key()],
511
+ queryFn: () => api.get(`/books?${catalog.serialize(filters.normalized())}`),
512
+ }));
513
+ ```
514
+
515
+ `SyncedState` works without the URL too, and takes any `ExternalStore` — `read`, `write` and a
516
+ `subscribe` that must not call back synchronously — for state kept somewhere else.
517
+
340
518
  ## Development
341
519
 
342
520
  ```sh
@@ -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">;