@viniaraujo68/plinth 0.3.1 → 0.5.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 (103) hide show
  1. package/README.md +189 -19
  2. package/dist/components/ErrorDisplay.svelte +30 -16
  3. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  4. package/dist/components/JsonView.svelte +43 -0
  5. package/dist/components/JsonView.svelte.d.ts +4 -0
  6. package/dist/components/JsonViewFrame.svelte +654 -0
  7. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  8. package/dist/components/Paginator.svelte +34 -8
  9. package/dist/components/Paginator.svelte.d.ts +10 -0
  10. package/dist/components/RequestErrorDisplay.svelte +43 -0
  11. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  12. package/dist/components/Tabs.svelte +204 -0
  13. package/dist/components/Tabs.svelte.d.ts +76 -0
  14. package/dist/components/VirtualJsonView.svelte +67 -0
  15. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  16. package/dist/components/index.d.ts +8 -1
  17. package/dist/components/index.js +11 -5
  18. package/dist/components/json-view.d.ts +70 -0
  19. package/dist/components/json-view.js +302 -0
  20. package/dist/components/request-error.d.ts +8 -0
  21. package/dist/components/request-error.js +55 -0
  22. package/dist/components/tabs.d.ts +5 -0
  23. package/dist/components/tabs.js +1 -0
  24. package/dist/http.d.ts +10 -5
  25. package/dist/http.js +34 -6
  26. package/dist/i18n/LocaleToggle.svelte +45 -0
  27. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  28. package/dist/i18n/i18n.svelte.d.ts +56 -0
  29. package/dist/i18n/i18n.svelte.js +94 -0
  30. package/dist/i18n/index.d.ts +3 -0
  31. package/dist/i18n/index.js +7 -0
  32. package/dist/i18n/translate.d.ts +30 -0
  33. package/dist/i18n/translate.js +38 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/redirect.d.ts +40 -0
  37. package/dist/redirect.js +59 -0
  38. package/dist/routing.svelte.d.ts +1 -0
  39. package/dist/routing.svelte.js +3 -0
  40. package/dist/shell/AppShell.svelte +265 -93
  41. package/dist/shell/AppShell.svelte.d.ts +3 -2
  42. package/dist/shell/NavigationProgress.svelte +85 -0
  43. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  44. package/dist/shell/TopBar.svelte +191 -0
  45. package/dist/shell/TopBar.svelte.d.ts +41 -0
  46. package/dist/shell/UserAvatar.svelte +50 -0
  47. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  48. package/dist/shell/index.d.ts +2 -0
  49. package/dist/shell/index.js +7 -3
  50. package/dist/shell/initials.d.ts +6 -0
  51. package/dist/shell/initials.js +13 -0
  52. package/dist/table/DataTable.svelte +199 -9
  53. package/dist/table/DataTable.svelte.d.ts +28 -5
  54. package/dist/table/FilterBar.svelte +417 -0
  55. package/dist/table/FilterBar.svelte.d.ts +34 -0
  56. package/dist/table/FilterRuleEditor.svelte +290 -0
  57. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  58. package/dist/table/filter-labels.d.ts +23 -0
  59. package/dist/table/filter-labels.js +30 -0
  60. package/dist/table/filter.d.ts +27 -0
  61. package/dist/table/filter.js +279 -0
  62. package/dist/table/index.d.ts +4 -1
  63. package/dist/table/index.js +5 -2
  64. package/dist/table/types.d.ts +49 -0
  65. package/dist/toast/Toaster.svelte +7 -3
  66. package/dist/toast/index.d.ts +1 -1
  67. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  68. package/dist/toast/toast-manager.svelte.js +61 -1
  69. package/dist/url-state/codec.d.ts +15 -0
  70. package/dist/url-state/codec.js +1 -0
  71. package/dist/url-state/index.d.ts +5 -0
  72. package/dist/url-state/index.js +4 -0
  73. package/dist/url-state/params-codec.d.ts +73 -0
  74. package/dist/url-state/params-codec.js +116 -0
  75. package/dist/url-state/search-params.d.ts +4 -0
  76. package/dist/url-state/search-params.js +28 -0
  77. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  78. package/dist/url-state/synced-state.svelte.js +88 -0
  79. package/dist/url-state/url-store.svelte.d.ts +12 -0
  80. package/dist/url-state/url-store.svelte.js +56 -0
  81. package/dist/virtual/DataList.svelte +215 -0
  82. package/dist/virtual/DataList.svelte.d.ts +52 -0
  83. package/dist/virtual/VirtualList.svelte +150 -0
  84. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  85. package/dist/virtual/index.d.ts +7 -0
  86. package/dist/virtual/index.js +6 -0
  87. package/dist/virtual/keyed-cache.d.ts +7 -0
  88. package/dist/virtual/keyed-cache.js +20 -0
  89. package/dist/virtual/labels.d.ts +12 -0
  90. package/dist/virtual/labels.js +6 -0
  91. package/dist/virtual/paging.d.ts +5 -0
  92. package/dist/virtual/paging.js +11 -0
  93. package/dist/virtual/range.d.ts +9 -0
  94. package/dist/virtual/range.js +31 -0
  95. package/dist/virtual/size-tree.d.ts +10 -0
  96. package/dist/virtual/size-tree.js +56 -0
  97. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  98. package/dist/virtual/virtualizer.svelte.js +295 -0
  99. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  100. package/dist/virtual/windowed-data.svelte.js +139 -0
  101. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  102. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  103. 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,71 @@ 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`.
278
317
 
279
318
  `JsonInput` edits a JSON document as text: it reports through a bindable `invalid` whether the text
280
319
  parses, shows the parser's message after the first blur, can reindent on blur, and sets the
281
320
  textarea's custom validity so a form will not submit a broken document. `parseJsonText` and
282
321
  `formatJsonText` are the same rules, for reading the text once it is sent.
283
322
 
323
+ `JsonView` shows a value read-only, as `JSON.stringify(value, null, 2)` would print it: `toJSON` is
324
+ honoured, so a `Date` shows as its ISO string, and a reference back to an ancestor shows a marker
325
+ instead of recursing. Nodes fold one by one, all at once or from `collapseDepth` down; the search
326
+ (Ctrl/Cmd+F while the viewer has the focus) reaches inside folds and unfolds what it lands on; and
327
+ copying — the whole document or a selection — writes the expanded text, never the fold summaries.
328
+
284
329
  Two of those pick one thing from a list, and which one to reach for is decided by the length of
285
330
  the list rather than by a flag:
286
331
 
@@ -337,6 +382,131 @@ Enter confirms only once it matches. The library ships no translations, so `conf
337
382
  `cancelLabel`, `closeLabel` and `challengeLabel` on the host translate every confirm at once, and
338
383
  any call overrides them through its own options.
339
384
 
385
+ ## Top bar, progress line and the sign-in round trip
386
+
387
+ `AppShell` is built for an app with a navigation to show. One with two or three pages and an
388
+ account puts **`TopBar`** over the page instead: the brand at the start, the app's controls at the
389
+ end, and — once the user context says someone is signed in — their avatar, opening an account menu
390
+ with their name, the app's own `<li>` entries and signing out. Choosing an entry closes the menu,
391
+ and `--plinth-top-bar-width` caps the bar's content to the width of the page under it.
392
+
393
+ ```svelte
394
+ <TopBar menuLabel={t("nav.accountMenu")} logoutLabel={t("nav.logout")}>
395
+ {#snippet brand()}<a href="/">Acme</a>{/snippet}
396
+ {#snippet actions()}
397
+ <LocaleToggle {i18n} label={t("nav.language")} />
398
+ <ThemeToggle iconOnly />
399
+ {/snippet}
400
+ {#snippet menu()}<li><a href="/account">{t("nav.account")}</a></li>{/snippet}
401
+ </TopBar>
402
+ ```
403
+
404
+ **`NavigationProgress`** goes with either shell: a line across the top of the window while a
405
+ navigation takes longer than `delay` (150ms). The library imports nothing from SvelteKit, so the app
406
+ says when one is under way — `<NavigationProgress active={navigating.to !== null} />`.
407
+
408
+ The routing entry point also carries the `?next=` round trip of a sign-in page. A guard sends a
409
+ visitor to **`loginUrl(page.url)`** — `/login?next=/where/they/were`, path only, since the query
410
+ string is where share tokens live. The sign-in page reads the parameter back through
411
+ **`safeNext`**, which keeps it only if the browser would resolve it to the same site:
412
+ `//evil.example`, `/\evil.example` and the spellings the URL parser turns into them come back
413
+ `null`, so a crafted link cannot send someone who just typed their password elsewhere.
414
+ **`withNext`** carries the same path across the link between sign-in and sign-up.
415
+
416
+ ## Languages
417
+
418
+ `@viniaraujo68/plinth/i18n` is for the app's own text — the library still ships no translations,
419
+ and its components keep taking every word through props. **`createI18n`** takes a flat dictionary
420
+ per locale and returns the current locale as `$state`, a `t()` that reads it, and `tag`, the BCP 47
421
+ tag for `Intl`:
422
+
423
+ ```ts
424
+ // src/lib/i18n.svelte.ts
425
+ import { createI18n } from "@viniaraujo68/plinth/i18n";
426
+ import pt from "./i18n/pt.js";
427
+ import en from "./i18n/en.js";
428
+
429
+ export const i18n = createI18n({
430
+ dictionaries: { pt, en },
431
+ tags: { pt: "pt-BR", en: "en-US" },
432
+ fallback: "pt",
433
+ unmatched: "en",
434
+ storageKey: "acme.locale",
435
+ });
436
+ export const { t } = i18n;
437
+ ```
438
+
439
+ `t("days", { count: 3 })` fills `{count}` and picks between a `{ one, other }` pair — exactly one
440
+ takes `one`, everything else, zero included, takes `other`, which is not what CLDR says for
441
+ Portuguese and is what its readers expect. A key missing from the current dictionary is read from the
442
+ fallback one. The first visit starts on the browser's language, matched by language when the region
443
+ differs, or on `unmatched` when nothing matches; a later one starts on the pick
444
+ **`LocaleToggle`** remembered. Switching relabels `<html lang>` too. The server renders the fallback
445
+ locale and never switches — the instance is shared by every request — and the browser corrects the
446
+ text during hydration.
447
+
448
+ ## URL state
449
+
450
+ `@viniaraujo68/plinth/url-state` keeps a screen's filters, sorting and paging in the query string,
451
+ so a link reopens what its sender was looking at and the back button undoes a search. It is state,
452
+ not a query layer: nothing in it fetches or caches.
453
+
454
+ ```svelte
455
+ <script lang="ts">
456
+ import { createParamsCodec, createUrlState, param } from "@viniaraujo68/plinth/url-state";
457
+
458
+ const catalog = createParamsCodec({
459
+ q: param.string({ default: "" }),
460
+ sort: param.string({ oneOf: ["asc", "desc"], default: "asc" }),
461
+ tags: param.list(param.string()),
462
+ page: param.number({ default: 1, min: 1, integer: true }),
463
+ });
464
+
465
+ const filters = createUrlState(catalog, { resetOnCommit: ["page"] });
466
+ </script>
467
+
468
+ <input bind:value={filters.draft.q} />
469
+ <button disabled={!filters.isDirty} onclick={filters.commit}>Apply</button>
470
+ <button onclick={() => filters.update((s) => ({ ...s, page: s.page + 1 }))}>Next</button>
471
+ ```
472
+
473
+ A form edits **`draft`**; **`commit()`** normalizes it into **`committed`** and writes the owned
474
+ params to the URL, leaving every other param alone. **`update(fn)`** commits one change eagerly —
475
+ a page turn — without dragging half-typed filters along; **`revert()`** drops the draft's edits and
476
+ **`reset()`** returns both to the defaults. **`resetOnCommit`** names the fields that go back to
477
+ their defaults whenever a commit changes anything else, which is what returns a new search to page 1.
478
+ A URL change that does not change **`key()`** — a foreign param, a reorder — is ignored, so it never
479
+ clobbers a draft; a real one, such as the back button, replaces both halves and calls `onSynced`.
480
+
481
+ The handlers never throw on a hand-edited URL: a value they cannot read falls back to the field's
482
+ default — `number` rejects `NaN` and empty values and honours `min`, `max` and `integer`, `boolean`
483
+ takes only `"true"` and `"false"`, `date` reads `YYYY-MM-DD` as local midnight and refuses
484
+ impossible days, and an invalid `Date` is never written. A value equal to its default stays out of
485
+ the URL. `list` reads a repeated param, and its default is always the empty list.
486
+
487
+ Every commit is a `goto` with focus and scroll kept, and pushes a history entry. **`replace`**
488
+ rewrites the current entry instead — `true` for search-as-you-type, or a function of the previous
489
+ and next owned params, where `changedParams` helps:
490
+ `replace: (prev, next) => changedParams(prev, next).every((name) => name === "q")`. Several states
491
+ on one page can commit in the same tick; the writes merge into one navigation.
492
+
493
+ Create it while a component initializes: it follows the URL through an `$effect`. Effects do not
494
+ run on the server, so a server-rendered or prerendered page renders the defaults and picks up the
495
+ URL once it hydrates. A page whose first paint has to reflect the query string reads it in its own
496
+ `load` and is not prerendered.
497
+
498
+ `key()` is what a fetch is keyed by. With TanStack Query it goes straight into the key:
499
+
500
+ ```ts
501
+ const books = createQuery(() => ({
502
+ queryKey: ["books", filters.key()],
503
+ queryFn: () => api.get(`/books?${catalog.serialize(filters.normalized())}`),
504
+ }));
505
+ ```
506
+
507
+ `SyncedState` works without the URL too, and takes any `ExternalStore` — `read`, `write` and a
508
+ `subscribe` that must not call back synchronously — for state kept somewhere else.
509
+
340
510
  ## Development
341
511
 
342
512
  ```sh
@@ -1,15 +1,23 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
2
4
  interface Props {
3
5
  /** Wire the `reset` a `svelte:boundary` hands to its `failed` snippet. Omitted, no button. */
4
6
  reset?: () => void;
5
7
  title?: string;
6
8
  message?: string;
9
+ eyebrow?: string;
10
+ retryLabel?: string;
11
+ icon?: Snippet;
7
12
  }
8
13
 
9
14
  let {
10
15
  reset,
11
16
  title = "Something went wrong",
12
17
  message = "The application ran into an unexpected problem.",
18
+ eyebrow = "Error",
19
+ retryLabel = "Try again",
20
+ icon,
13
21
  }: Props = $props();
14
22
  </script>
15
23
 
@@ -34,26 +42,32 @@ counted once per render. Wire `reportError` on the boundary's `onerror` instead.
34
42
  class="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center"
35
43
  role="alert"
36
44
  >
37
- <svg
38
- class="size-12 text-base-content/40"
39
- viewBox="0 0 24 24"
40
- fill="none"
41
- stroke="currentColor"
42
- stroke-width="1.5"
43
- stroke-linecap="round"
44
- stroke-linejoin="round"
45
- aria-hidden="true"
46
- >
47
- <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
48
- <path d="M12 9v4" />
49
- <path d="M12 17h.01" />
50
- </svg>
51
- <p class="text-xs font-medium tracking-widest text-base-content/55 uppercase">Error</p>
45
+ {#if icon}
46
+ {@render icon()}
47
+ {:else}
48
+ <svg
49
+ class="size-12 text-base-content/40"
50
+ viewBox="0 0 24 24"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="1.5"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ aria-hidden="true"
57
+ >
58
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
59
+ <path d="M12 9v4" />
60
+ <path d="M12 17h.01" />
61
+ </svg>
62
+ {/if}
63
+ {#if eyebrow}
64
+ <p class="text-xs font-medium tracking-widest text-base-content/55 uppercase">{eyebrow}</p>
65
+ {/if}
52
66
  <p class="text-xl font-semibold">{title}</p>
53
67
  <p class="max-w-md text-sm text-base-content/60">{message}</p>
54
68
  {#if reset}
55
69
  <div class="mt-4">
56
- <button type="button" class="btn btn-primary" onclick={reset}>Try again</button>
70
+ <button type="button" class="btn btn-primary" onclick={reset}>{retryLabel}</button>
57
71
  </div>
58
72
  {/if}
59
73
  </div>
@@ -1,8 +1,12 @@
1
+ import type { Snippet } from "svelte";
1
2
  interface Props {
2
3
  /** Wire the `reset` a `svelte:boundary` hands to its `failed` snippet. Omitted, no button. */
3
4
  reset?: () => void;
4
5
  title?: string;
5
6
  message?: string;
7
+ eyebrow?: string;
8
+ retryLabel?: string;
9
+ icon?: Snippet;
6
10
  }
7
11
  /**
8
12
  * Calm in-place fallback for a crashed subtree: it says what happened and offers the way out.
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import JsonViewFrame, {
3
+ type JsonViewProps,
4
+ type JsonViewRevealTarget,
5
+ } from "./JsonViewFrame.svelte";
6
+
7
+ const props: JsonViewProps = $props();
8
+
9
+ let scroller = $state<HTMLDivElement>();
10
+
11
+ const reveal = ({ line }: JsonViewRevealTarget) => {
12
+ const row = scroller?.querySelector<HTMLElement>(`[data-line="${line}"]`);
13
+ const target = row?.querySelector<HTMLElement>("[data-active]") ?? row;
14
+ target?.scrollIntoView({ block: "nearest", inline: "nearest" });
15
+ };
16
+ </script>
17
+
18
+ <JsonViewFrame {...props} {reveal}>
19
+ {#snippet viewport({ visible, line, rowId, widthCh })}
20
+ <div bind:this={scroller} class="plinth-json-scroller">
21
+ <div class="plinth-json-lines" style:min-width="max(100%, {widthCh}ch)">
22
+ {#each visible as index (rowId(index))}
23
+ {@render line(index)}
24
+ {/each}
25
+ </div>
26
+ </div>
27
+ {/snippet}
28
+ </JsonViewFrame>
29
+
30
+ <style>
31
+ .plinth-json-scroller {
32
+ flex: 1;
33
+ min-height: 0;
34
+ overflow: auto;
35
+ padding-block: 0.25rem;
36
+ }
37
+
38
+ .plinth-json-lines {
39
+ width: max-content;
40
+ min-width: 100%;
41
+ padding-inline-end: 1ch;
42
+ }
43
+ </style>
@@ -0,0 +1,4 @@
1
+ import { type JsonViewProps } from "./JsonViewFrame.svelte";
2
+ declare const JsonView: import("svelte").Component<JsonViewProps, {}, "">;
3
+ type JsonView = ReturnType<typeof JsonView>;
4
+ export default JsonView;