@poodle64/ui 2026.10.17 → 2026.10.18

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 (57) hide show
  1. package/README.md +94 -11
  2. package/dist/components/feedback/report-widget.svelte +4 -1
  3. package/dist/components/feedback/report-widget.svelte.d.ts +1 -0
  4. package/dist/components/ui/app-identity/app-identity.svelte +7 -2
  5. package/dist/components/ui/app-shell/texture.d.ts +5 -5
  6. package/dist/components/ui/app-shell/texture.js +3 -3
  7. package/dist/components/ui/breadcrumbs/breadcrumbs.svelte +70 -0
  8. package/dist/components/ui/breadcrumbs/breadcrumbs.svelte.d.ts +32 -0
  9. package/dist/components/ui/breadcrumbs/index.d.ts +2 -0
  10. package/dist/components/ui/breadcrumbs/index.js +2 -0
  11. package/dist/components/ui/content-skeleton/content-skeleton.svelte +94 -0
  12. package/dist/components/ui/content-skeleton/content-skeleton.svelte.d.ts +25 -0
  13. package/dist/components/ui/content-skeleton/index.d.ts +2 -0
  14. package/dist/components/ui/content-skeleton/index.js +2 -0
  15. package/dist/components/ui/document-reader/document-reader.svelte +336 -0
  16. package/dist/components/ui/document-reader/document-reader.svelte.d.ts +65 -0
  17. package/dist/components/ui/document-reader/index.d.ts +2 -0
  18. package/dist/components/ui/document-reader/index.js +2 -0
  19. package/dist/components/ui/page-container/index.d.ts +2 -0
  20. package/dist/components/ui/page-container/index.js +2 -0
  21. package/dist/components/ui/page-container/page-container.svelte +39 -0
  22. package/dist/components/ui/page-container/page-container.svelte.d.ts +24 -0
  23. package/dist/components/ui/page-header/page-header.svelte +12 -7
  24. package/dist/components/ui/page-header/page-header.svelte.d.ts +3 -2
  25. package/dist/components/ui/page-section/index.d.ts +2 -0
  26. package/dist/components/ui/page-section/index.js +2 -0
  27. package/dist/components/ui/page-section/page-section.svelte +92 -0
  28. package/dist/components/ui/page-section/page-section.svelte.d.ts +36 -0
  29. package/dist/components/ui/sheet/index.d.ts +13 -0
  30. package/dist/components/ui/sheet/index.js +15 -0
  31. package/dist/components/ui/sheet/sheet-body.svelte +21 -0
  32. package/dist/components/ui/sheet/sheet-body.svelte.d.ts +5 -0
  33. package/dist/components/ui/sheet/sheet-close.svelte +11 -0
  34. package/dist/components/ui/sheet/sheet-close.svelte.d.ts +4 -0
  35. package/dist/components/ui/sheet/sheet-content.svelte +70 -0
  36. package/dist/components/ui/sheet/sheet-content.svelte.d.ts +37 -0
  37. package/dist/components/ui/sheet/sheet-description.svelte +20 -0
  38. package/dist/components/ui/sheet/sheet-description.svelte.d.ts +4 -0
  39. package/dist/components/ui/sheet/sheet-footer.svelte +23 -0
  40. package/dist/components/ui/sheet/sheet-footer.svelte.d.ts +5 -0
  41. package/dist/components/ui/sheet/sheet-header.svelte +21 -0
  42. package/dist/components/ui/sheet/sheet-header.svelte.d.ts +5 -0
  43. package/dist/components/ui/sheet/sheet-overlay.svelte +20 -0
  44. package/dist/components/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
  45. package/dist/components/ui/sheet/sheet-portal.svelte +7 -0
  46. package/dist/components/ui/sheet/sheet-portal.svelte.d.ts +3 -0
  47. package/dist/components/ui/sheet/sheet-title.svelte +21 -0
  48. package/dist/components/ui/sheet/sheet-title.svelte.d.ts +4 -0
  49. package/dist/components/ui/sheet/sheet-trigger.svelte +11 -0
  50. package/dist/components/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
  51. package/dist/components/ui/sheet/sheet.svelte +7 -0
  52. package/dist/components/ui/sheet/sheet.svelte.d.ts +3 -0
  53. package/dist/components/ui/skeleton/skeleton.svelte +1 -1
  54. package/dist/styles.css +8 -0
  55. package/package.json +1 -1
  56. package/registry/component-map.json +43 -4
  57. package/registry/component-map.md +9 -4
package/README.md CHANGED
@@ -47,22 +47,25 @@ src/lib/
47
47
  dist/ generated by `pnpm build` (@sveltejs/package); never edit
48
48
  ```
49
49
 
50
- **Primitives** (26 — battle-tested implementations pulled from whichever app had
50
+ **Primitives** (27 — battle-tested implementations pulled from whichever app had
51
51
  them first, not an invented "ideal" list): `alert`, `alert-dialog`, `avatar`,
52
52
  `badge`, `button`, `card`, `checkbox`, `command`, `data-table`, `dialog`,
53
53
  `dropdown-menu`, `input`, `input-group`, `label`, `password-input`, `popover`,
54
- `progress`, `select`, `separator`, `skeleton`, `sonner`, `switch`, `table`,
55
- `tabs`, `textarea`, `tooltip`.
54
+ `progress`, `select`, `separator`, `sheet`, `skeleton`, `sonner`, `switch`,
55
+ `table`, `tabs`, `textarea`, `tooltip`.
56
56
 
57
57
  `avatar` is root, image and fallback only; the group and badge variants in the
58
58
  upstream registry had no consumer in any surveyed app. It is here because
59
59
  `AppShell`'s `identity` slot needs one, and a consumer filling a slot the shell
60
60
  itself defines should not have to keep a private copy of a primitive to do it.
61
61
 
62
- Not yet included: `sheet` — no app's `ui/` has vendored it yet. Add it here
63
- (`pnpm dlx shadcn-svelte@latest add <name>` inside `packages/ui`, or hand-port from
64
- a sibling app once one adopts it) the first time a converging app actually needs it;
65
- do not invent it speculatively.
62
+ `sheet` is shadcn's API (`Sheet.Root`, `Trigger`, `Content side=…`, `Header`,
63
+ `Title`, `Description`, `Footer`, `Close`) plus `Sheet.Body`, the part that
64
+ scrolls between a fixed header and footer. It is a panel over the page, for a
65
+ task beside what you are reading (comments on a chapter, a cited record on a
66
+ phone); it is never a second navigation column, which the shell has no slot
67
+ for. It stands on the kit's hairline depth, not a shadow, and inside an
68
+ element's `<Overlays>` it portals into the shadow root like the dialogue does.
66
69
 
67
70
  **Deliberately not here: `chart` and `form`.** Two apps keep local copies of each,
68
71
  and both stay local for reasons that are about coupling rather than effort.
@@ -89,7 +92,12 @@ rather than rebuilding it:
89
92
 
90
93
  | Import | What it is |
91
94
  | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
- | `page-header` | The only page-title pattern: optional `breadcrumbs` and `icon` snippets, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
95
+ | `page-header` | The only page-title pattern: optional `breadcrumbs` (the trail's items, or a snippet) and `icon`, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
96
+ | `breadcrumbs` | The trail: `items` of `{ label, href? }`, a named landmark, chevrons, each step truncating rather than wrapping. PageHeader draws it from `breadcrumbs={[…]}`. See [The page](#the-page-container-sections-and-the-type-scale). |
97
+ | `page-container` | A route's page at the one rhythm: header, sections and panels 1.5rem apart, filling the content box. |
98
+ | `page-section` | A section of a page: `title` at `level` 2 or 3, an optional `description`, `count` and `actions`, and its content at one distance below. |
99
+ | `document-reader` | A document to read: PageHeader, the body (`html` the app sanitised, or `children`) in `.ds-prose` at the reading measure, and a contents list from its own headings. See [A document to read](#a-document-to-read). |
100
+ | `content-skeleton` | A region loading, shaped like what replaces it: `rows`, `tiles` or `prose`. A whole page waiting is LoadingState. |
93
101
  | `panel` | The generic titled card, and the settings section: optional icon, a clamped one-line `subtitle` or a wrapping `description`, trailing `action`s, a body that can opt out of padding or scroll inside the card (`scroll`), an optional `footer` strip for Save/Cancel, and `tone="destructive"` for a danger zone. There is no `SettingsSection`; see [The settings destination](#the-settings-destination). |
94
102
  | `list-toolbar` | The bar above a list, on the page ground: title, count, icon actions named by their tooltips, one labelled action. The Ledger and the RecordList draw theirs with it. |
95
103
  | `record-list` | The ledger's look for a list that is not money. See [Lists that are not money](#lists-that-are-not-money). |
@@ -534,9 +542,13 @@ Two things worth knowing before you set it:
534
542
  replaces. An app running `padded={false}` and its own page padding gets the cap
535
543
  flush, which is usually what it wanted.
536
544
 
537
- Set it in the layout, not the page. A page reaching for `measure` is the habit
538
- this replaces; a route group that genuinely differs (a docs section inside an
539
- app of dashboards) gets its own layout, which is where a shared decision belongs.
545
+ A route declares it in its load data and the app's frame passes it on: the
546
+ stamped frame renders `measure={page.data.measure ?? 'wide'}`, and a route group
547
+ returns `{ measure: 'page' }` from its `+layout.ts`, one route from its
548
+ `+page.ts`. Three apps had each written a table of path regexes mapping routes
549
+ to tiers in their frame (22 rows in one); route data puts the decision on the
550
+ route it belongs to, and SvelteKit merges a layout's answer into every page
551
+ under it. `padded` travels the same way.
540
552
 
541
553
  #### A block inside the page: `.ds-measure`
542
554
 
@@ -562,6 +574,77 @@ paragraphs ~207px away from their own label. Both halves are gated in
562
574
  on the page's own left edge, and it follows a retune of
563
575
  `--ds-shell-measure-prose`.
564
576
 
577
+ ### The page: container, sections and the type scale
578
+
579
+ ```svelte
580
+ <PageContainer>
581
+ <PageHeader
582
+ title="Allowances"
583
+ breadcrumbs={[{ label: 'Library', href: '/library' }, { label: 'Pay and conditions' }]}
584
+ />
585
+ <PageSection title="Recently changed" count={4} description="Clauses amended this quarter.">
586
+ {#snippet actions()}<Button variant="outline" size="sm">Compare</Button>{/snippet}
587
+ <RecordList … />
588
+ </PageSection>
589
+ </PageContainer>
590
+ ```
591
+
592
+ A step with an `href` is a way back; the last step without one is where you are
593
+ (`aria-current="page"`). The trail is a `<nav aria-label="Breadcrumb">`, which
594
+ none of the three hand-written copies had.
595
+
596
+ The type scale, by role. An app sets none of these; a route sets no size of its
597
+ own and composes the component that owns the role:
598
+
599
+ | Role | Utility | Size | Face | Owned by |
600
+ | --- | --- | --- | --- | --- |
601
+ | Page title | `text-display` | 1.75rem | display, semibold | PageHeader |
602
+ | Section | `text-section` | 1.25rem | display, semibold | PageSection `level={2}` |
603
+ | Panel title, subsection | `text-body` | 0.9375rem | body, semibold | Panel, PageSection `level={3}` |
604
+ | Readout | `text-stat` | 1.0625rem | body | StatList |
605
+ | Reading text | `text-base` | 1rem | body | DocumentReader |
606
+ | Body | `text-sm` | 0.875rem | body | the default |
607
+ | Facts, captions | `text-xs` | 0.75rem | body, muted | PageHeader `meta` |
608
+ | Column head | `text-2xs`, uppercase, `tracking-eyebrow` | 0.6875rem | body, medium | tables, nav groups |
609
+
610
+ PageSection has no uppercase-kicker form. One app had five recipes for a
611
+ section heading, three of them small caps; small caps over content is a column
612
+ head, and as a heading it is the generated-UI tell `eyebrow` warns about.
613
+
614
+ ### A document to read
615
+
616
+ ```svelte
617
+ <DocumentReader
618
+ title={doc.title}
619
+ breadcrumbs={[{ label: 'Research notes', href: '/research' }, { label: doc.title }]}
620
+ html={sanitised}
621
+ {loading}
622
+ {error}
623
+ {onRetry}
624
+ >
625
+ {#snippet meta()}<span>12 min read</span>{/snippet}
626
+ {#snippet footer()}<a href={next.href}>Next: {next.title}</a>{/snippet}
627
+ </DocumentReader>
628
+ ```
629
+
630
+ The body is the app's HTML, already sanitised, for the reason `.ds-prose` gives;
631
+ strip the body's own leading `# Title`, since the header carries it. The
632
+ contents list is the body's `h2` and `h3`, shown once there are two; a heading
633
+ without an id gets one from its text. From a reader 64rem wide the list stands
634
+ beside the body and stays in view; narrower, it folds into an "On this page"
635
+ disclosure above it. That is a container query on the reader, so a route at
636
+ `measure="page"` or wider shows it beside the body on a laptop. A link scrolls
637
+ to its heading and moves focus there, without the URL fragment, which cannot
638
+ reach into a shadow root.
639
+
640
+ `aside` adds to that column, under the contents, and follows the body on a
641
+ narrower reader: a library document's catalogue facts belong there, and
642
+ DocumentDetail stays the record for a document with no body to read. The column
643
+ is held while the body loads, so a structured document does not move sideways
644
+ as it arrives; a body with fewer than two headings and no `aside` gives the room
645
+ back. With a `title`, `loading` keeps the header and draws the body as a
646
+ `prose` ContentSkeleton; without one it is LoadingState.
647
+
565
648
  ### The content texture
566
649
 
567
650
  The house atmosphere — a faint dot-grid floor with a soft accent vignette in the
@@ -33,10 +33,13 @@
33
33
  let {
34
34
  /** Where the report POSTs. The app's own route, defaulting to the one every household app serves. */
35
35
  endpoint = '/api/feedback',
36
+ /** Who reads a report, as "the <team> team". The app's own name, never an internal one. */
37
+ team = 'support',
36
38
  /** Fired with the id the endpoint returned, once, on the success leg. */
37
39
  onSubmitted
38
40
  }: {
39
41
  endpoint?: string;
42
+ team?: string;
40
43
  onSubmitted?: (id: string) => void;
41
44
  } = $props();
42
45
 
@@ -129,7 +132,7 @@
129
132
  <AppDialog
130
133
  bind:open
131
134
  title="Report a problem"
132
- subtitle="Describe what happened and the household team will take a look."
135
+ subtitle="Describe what happened and the {team} team will take a look."
133
136
  size="sm"
134
137
  onOpenChange={(next) => !next && reset()}
135
138
  >
@@ -1,5 +1,6 @@
1
1
  type $$ComponentProps = {
2
2
  endpoint?: string;
3
+ team?: string;
3
4
  onSubmitted?: (id: string) => void;
4
5
  };
5
6
  declare const ReportWidget: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -143,6 +143,11 @@
143
143
  if (onSwitchTheme) onSwitchTheme();
144
144
  else toggleMode();
145
145
  }
146
+
147
+ // Each name once: the identity provider repeats a name for every binding
148
+ // that grants it, and a repeated key throws in the list below, which took
149
+ // one app's whole account menu down with it.
150
+ const held = $derived([...new Set(entitlements)]);
146
151
  </script>
147
152
 
148
153
  <DropdownMenu.Root>
@@ -193,9 +198,9 @@
193
198
  </span>
194
199
  {/if}
195
200
  </div>
196
- {#if entitlements.length}
201
+ {#if held.length}
197
202
  <div class="flex flex-wrap gap-1 px-2 pb-2">
198
- {#each entitlements as e (e)}
203
+ {#each held as e (e)}
199
204
  <span class="bg-surface-2 text-muted-foreground font-mono rounded px-1.5 py-px text-[10px]">
200
205
  {e}
201
206
  </span>
@@ -12,8 +12,8 @@
12
12
  * it is a coin toss with a stylesheet.
13
13
  *
14
14
  * So it moves onto the shell, where there is exactly one place to say it and no
15
- * per-route decision left to get wrong. `none` is the default, so a shell that
16
- * never mentions it renders byte-identically.
15
+ * per-route decision left to get wrong. `grid` is the default: an opt-in house
16
+ * style measured who remembered it, not what the house looks like.
17
17
  *
18
18
  * It is one texture, not a menu. Both apps wanted the same picture, and the
19
19
  * axes they actually differed on — how dark the dots are, how the vignette is
@@ -24,9 +24,9 @@
24
24
  * remains.
25
25
  */
26
26
  /**
27
- * - `grid` — the house atmosphere: a dot-grid floor plus a corner vignette.
28
- * - `none` — no texture. The default, so omitting `texture` changes nothing.
27
+ * - `grid` — the house atmosphere: a dot-grid floor plus a corner vignette. The default.
28
+ * - `none` — no texture: the region exactly as it rendered before the feature.
29
29
  */
30
30
  export type ShellTexture = 'none' | 'grid';
31
- /** Every texture, the untextured default first — the order it is documented in. */
31
+ /** Every texture, the untextured form first — the order it is documented in. */
32
32
  export declare const SHELL_TEXTURES: readonly ["none", "grid"];
@@ -12,8 +12,8 @@
12
12
  * it is a coin toss with a stylesheet.
13
13
  *
14
14
  * So it moves onto the shell, where there is exactly one place to say it and no
15
- * per-route decision left to get wrong. `none` is the default, so a shell that
16
- * never mentions it renders byte-identically.
15
+ * per-route decision left to get wrong. `grid` is the default: an opt-in house
16
+ * style measured who remembered it, not what the house looks like.
17
17
  *
18
18
  * It is one texture, not a menu. Both apps wanted the same picture, and the
19
19
  * axes they actually differed on — how dark the dots are, how the vignette is
@@ -23,5 +23,5 @@
23
23
  * second name in this union: set the grid ink to `transparent` and the vignette
24
24
  * remains.
25
25
  */
26
- /** Every texture, the untextured default first — the order it is documented in. */
26
+ /** Every texture, the untextured form first — the order it is documented in. */
27
27
  export const SHELL_TEXTURES = ['none', 'grid'];
@@ -0,0 +1,70 @@
1
+ <script lang="ts" module>
2
+ /** One step of the trail. With `href` it is a way back; without, it is where you are. */
3
+ export type BreadcrumbItem = { label: string; href?: string };
4
+ </script>
5
+
6
+ <script lang="ts">
7
+ /**
8
+ * Where you are, as a trail of the routes above it. Three apps had written
9
+ * this by hand (one copy three times, one as a 28-call-site wrapper, one with
10
+ * slash separators), and none named the landmark, so a screen reader met an
11
+ * unlabelled list of links.
12
+ *
13
+ * The links are plain `<a href>`: SvelteKit routes them client-side, so a
14
+ * package with no router of its own can still render the app's routes, as
15
+ * AppNav does for `NavItem.href`.
16
+ *
17
+ * The trail never wraps. Each step truncates instead: an ancestor at 40% of
18
+ * its container (the viewport when there is none) up to 32rem, and three
19
+ * times as readily as where you are, so on a phone one long collection name
20
+ * gives way while short steps and the current one stay whole, and on a
21
+ * laptop it shows in full. Not a percentage: PageHeader's title column is
22
+ * as wide as its content, so 40% of it would truncate at any width.
23
+ */
24
+ import type { HTMLAttributes } from 'svelte/elements';
25
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
26
+ import { cn, type WithElementRef } from '../../../utils.js';
27
+
28
+ let {
29
+ items,
30
+ label = 'Breadcrumb',
31
+ ref = $bindable(null),
32
+ class: className,
33
+ ...restProps
34
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
35
+ items: BreadcrumbItem[];
36
+ /** The landmark's accessible name. */
37
+ label?: string;
38
+ } = $props();
39
+ </script>
40
+
41
+ <nav
42
+ bind:this={ref}
43
+ aria-label={label}
44
+ data-slot="breadcrumbs"
45
+ class={cn('text-muted-foreground min-w-0 text-sm', className)}
46
+ {...restProps}
47
+ >
48
+ <ol class="flex min-w-0 items-center gap-1.5">
49
+ {#each items as item, i (i)}
50
+ {@const last = i === items.length - 1}
51
+ <li class={cn('flex min-w-0 items-center gap-1.5', !last && 'max-w-[min(40cqi,32rem)] shrink-[3]')}>
52
+ {#if i > 0}
53
+ <ChevronRight class="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
54
+ {/if}
55
+ {#if item.href}
56
+ <a
57
+ href={item.href}
58
+ class="hover:text-foreground focus-visible:ring-ring/50 min-w-0 truncate rounded-sm underline-offset-4 outline-none hover:underline focus-visible:ring-3"
59
+ >{item.label}</a
60
+ >
61
+ {:else}
62
+ <span
63
+ class="text-foreground min-w-0 truncate"
64
+ aria-current={last ? 'page' : undefined}>{item.label}</span
65
+ >
66
+ {/if}
67
+ </li>
68
+ {/each}
69
+ </ol>
70
+ </nav>
@@ -0,0 +1,32 @@
1
+ /** One step of the trail. With `href` it is a way back; without, it is where you are. */
2
+ export type BreadcrumbItem = {
3
+ label: string;
4
+ href?: string;
5
+ };
6
+ /**
7
+ * Where you are, as a trail of the routes above it. Three apps had written
8
+ * this by hand (one copy three times, one as a 28-call-site wrapper, one with
9
+ * slash separators), and none named the landmark, so a screen reader met an
10
+ * unlabelled list of links.
11
+ *
12
+ * The links are plain `<a href>`: SvelteKit routes them client-side, so a
13
+ * package with no router of its own can still render the app's routes, as
14
+ * AppNav does for `NavItem.href`.
15
+ *
16
+ * The trail never wraps. Each step truncates instead: an ancestor at 40% of
17
+ * its container (the viewport when there is none) up to 32rem, and three
18
+ * times as readily as where you are, so on a phone one long collection name
19
+ * gives way while short steps and the current one stay whole, and on a
20
+ * laptop it shows in full. Not a percentage: PageHeader's title column is
21
+ * as wide as its content, so 40% of it would truncate at any width.
22
+ */
23
+ import type { HTMLAttributes } from 'svelte/elements';
24
+ import { type WithElementRef } from '../../../utils.js';
25
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
26
+ items: BreadcrumbItem[];
27
+ /** The landmark's accessible name. */
28
+ label?: string;
29
+ };
30
+ declare const Breadcrumbs: import("svelte").Component<$$ComponentProps, {}, "ref">;
31
+ type Breadcrumbs = ReturnType<typeof Breadcrumbs>;
32
+ export default Breadcrumbs;
@@ -0,0 +1,2 @@
1
+ export { default as Breadcrumbs, type BreadcrumbItem } from './breadcrumbs.svelte';
2
+ export { default } from './breadcrumbs.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as Breadcrumbs } from './breadcrumbs.svelte';
2
+ export { default } from './breadcrumbs.svelte';
@@ -0,0 +1,94 @@
1
+ <script lang="ts" module>
2
+ export type ContentSkeletonShape = 'rows' | 'tiles' | 'prose';
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * A region of a page while its data loads, drawn in the shape of what will
8
+ * replace it: `rows` for a RecordList or a table, `tiles` for StatCards or a
9
+ * card grid, `prose` for a document body. The page renders its PageHeader
10
+ * for real and this in place of the region; a whole page still waiting on
11
+ * everything is LoadingState's (`sveltekit-ui-patterns.md` §Loading).
12
+ *
13
+ * Each shape is sized to the component it stands in for (a RecordList row is
14
+ * 2.5rem of text in 0.625rem of padding, a StatCard a label, a display
15
+ * figure and a line), so the swap moves nothing. One app had five sizes of
16
+ * bare Skeleton box for this, none matching what loaded.
17
+ */
18
+ import type { HTMLAttributes } from 'svelte/elements';
19
+ import { cn, type WithElementRef } from '../../../utils.js';
20
+ import Skeleton from '../skeleton/skeleton.svelte';
21
+
22
+ let {
23
+ shape = 'rows',
24
+ count,
25
+ label = 'Loading…',
26
+ ref = $bindable(null),
27
+ class: className,
28
+ ...restProps
29
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
30
+ shape?: ContentSkeletonShape;
31
+ /** Rows, tiles, or paragraphs. Defaults to 5, 4 and 3. */
32
+ count?: number;
33
+ /** What a screen reader hears while it stands. */
34
+ label?: string;
35
+ } = $props();
36
+
37
+ const n = $derived(count ?? { rows: 5, tiles: 4, prose: 3 }[shape]);
38
+ // Varied, but fixed: a skeleton that changed width on every render would
39
+ // read as motion.
40
+ const TITLE = ['w-2/5', 'w-3/5', 'w-1/2', 'w-2/3', 'w-1/3'];
41
+ const NOTE = ['w-1/4', 'w-1/3', 'w-1/5', 'w-2/5'];
42
+ const LAST_LINE = ['w-3/5', 'w-2/5', 'w-4/5'];
43
+ </script>
44
+
45
+ <div
46
+ bind:this={ref}
47
+ role="status"
48
+ aria-live="polite"
49
+ aria-busy="true"
50
+ data-slot="content-skeleton"
51
+ data-shape={shape}
52
+ class={cn('@container min-w-0', className)}
53
+ {...restProps}
54
+ >
55
+ <span class="sr-only">{label}</span>
56
+ {#if shape === 'rows'}
57
+ <div class="bg-card border-border ds-edge divide-border divide-y rounded-lg border" aria-hidden="true">
58
+ {#each { length: n }, i (i)}
59
+ <div class="flex flex-col gap-1 px-3 py-2.5">
60
+ <div class="flex h-5 items-center">
61
+ <Skeleton class={cn('h-3.5', TITLE[i % TITLE.length])} />
62
+ </div>
63
+ <div class="flex h-4 items-center">
64
+ <Skeleton class={cn('h-3', NOTE[i % NOTE.length])} />
65
+ </div>
66
+ </div>
67
+ {/each}
68
+ </div>
69
+ {:else if shape === 'tiles'}
70
+ <div class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @3xl:grid-cols-3 @5xl:grid-cols-4" aria-hidden="true">
71
+ {#each { length: n }, i (i)}
72
+ <div class="bg-card border-border ds-edge flex flex-col gap-1.5 rounded-lg border p-4">
73
+ <div class="flex h-4 items-center"><Skeleton class="h-2.5 w-1/3" /></div>
74
+ <div class="flex h-[2.125rem] items-center"><Skeleton class="h-6 w-1/2" /></div>
75
+ <div class="flex h-4 items-center">
76
+ <Skeleton class={cn('h-3', NOTE[i % NOTE.length])} />
77
+ </div>
78
+ </div>
79
+ {/each}
80
+ </div>
81
+ {:else}
82
+ <div class="ds-measure flex flex-col gap-5" data-measure="prose" aria-hidden="true">
83
+ {#each { length: n }, i (i)}
84
+ <div class="flex flex-col">
85
+ {#each { length: 4 }, line (line)}
86
+ <div class="flex h-6 items-center">
87
+ <Skeleton class={cn('h-3.5', line === 3 ? LAST_LINE[i % LAST_LINE.length] : 'w-full')} />
88
+ </div>
89
+ {/each}
90
+ </div>
91
+ {/each}
92
+ </div>
93
+ {/if}
94
+ </div>
@@ -0,0 +1,25 @@
1
+ export type ContentSkeletonShape = 'rows' | 'tiles' | 'prose';
2
+ /**
3
+ * A region of a page while its data loads, drawn in the shape of what will
4
+ * replace it: `rows` for a RecordList or a table, `tiles` for StatCards or a
5
+ * card grid, `prose` for a document body. The page renders its PageHeader
6
+ * for real and this in place of the region; a whole page still waiting on
7
+ * everything is LoadingState's (`sveltekit-ui-patterns.md` §Loading).
8
+ *
9
+ * Each shape is sized to the component it stands in for (a RecordList row is
10
+ * 2.5rem of text in 0.625rem of padding, a StatCard a label, a display
11
+ * figure and a line), so the swap moves nothing. One app had five sizes of
12
+ * bare Skeleton box for this, none matching what loaded.
13
+ */
14
+ import type { HTMLAttributes } from 'svelte/elements';
15
+ import { type WithElementRef } from '../../../utils.js';
16
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
17
+ shape?: ContentSkeletonShape;
18
+ /** Rows, tiles, or paragraphs. Defaults to 5, 4 and 3. */
19
+ count?: number;
20
+ /** What a screen reader hears while it stands. */
21
+ label?: string;
22
+ };
23
+ declare const ContentSkeleton: import("svelte").Component<$$ComponentProps, {}, "ref">;
24
+ type ContentSkeleton = ReturnType<typeof ContentSkeleton>;
25
+ export default ContentSkeleton;
@@ -0,0 +1,2 @@
1
+ export { default as ContentSkeleton, type ContentSkeletonShape } from './content-skeleton.svelte';
2
+ export { default } from './content-skeleton.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ContentSkeleton } from './content-skeleton.svelte';
2
+ export { default } from './content-skeleton.svelte';