@poodle64/ui 2026.10.16 → 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.
- package/README.md +135 -11
- package/dist/components/feedback/report-widget.svelte +4 -1
- package/dist/components/feedback/report-widget.svelte.d.ts +1 -0
- package/dist/components/ui/app-identity/app-identity.svelte +7 -2
- package/dist/components/ui/app-shell/texture.d.ts +5 -5
- package/dist/components/ui/app-shell/texture.js +3 -3
- package/dist/components/ui/breadcrumbs/breadcrumbs.svelte +70 -0
- package/dist/components/ui/breadcrumbs/breadcrumbs.svelte.d.ts +32 -0
- package/dist/components/ui/breadcrumbs/index.d.ts +2 -0
- package/dist/components/ui/breadcrumbs/index.js +2 -0
- package/dist/components/ui/content-skeleton/content-skeleton.svelte +94 -0
- package/dist/components/ui/content-skeleton/content-skeleton.svelte.d.ts +25 -0
- package/dist/components/ui/content-skeleton/index.d.ts +2 -0
- package/dist/components/ui/content-skeleton/index.js +2 -0
- package/dist/components/ui/document-reader/document-reader.svelte +336 -0
- package/dist/components/ui/document-reader/document-reader.svelte.d.ts +65 -0
- package/dist/components/ui/document-reader/index.d.ts +2 -0
- package/dist/components/ui/document-reader/index.js +2 -0
- package/dist/components/ui/graph-canvas/graph-canvas.svelte +922 -0
- package/dist/components/ui/graph-canvas/graph-canvas.svelte.d.ts +73 -0
- package/dist/components/ui/graph-canvas/index.d.ts +3 -0
- package/dist/components/ui/graph-canvas/index.js +2 -0
- package/dist/components/ui/graph-canvas/scene.d.ts +107 -0
- package/dist/components/ui/graph-canvas/scene.js +562 -0
- package/dist/components/ui/graph-canvas/types.d.ts +33 -0
- package/dist/components/ui/graph-canvas/types.js +1 -0
- package/dist/components/ui/page-container/index.d.ts +2 -0
- package/dist/components/ui/page-container/index.js +2 -0
- package/dist/components/ui/page-container/page-container.svelte +39 -0
- package/dist/components/ui/page-container/page-container.svelte.d.ts +24 -0
- package/dist/components/ui/page-header/page-header.svelte +12 -7
- package/dist/components/ui/page-header/page-header.svelte.d.ts +3 -2
- package/dist/components/ui/page-section/index.d.ts +2 -0
- package/dist/components/ui/page-section/index.js +2 -0
- package/dist/components/ui/page-section/page-section.svelte +92 -0
- package/dist/components/ui/page-section/page-section.svelte.d.ts +36 -0
- package/dist/components/ui/sheet/index.d.ts +13 -0
- package/dist/components/ui/sheet/index.js +15 -0
- package/dist/components/ui/sheet/sheet-body.svelte +21 -0
- package/dist/components/ui/sheet/sheet-body.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-close.svelte +11 -0
- package/dist/components/ui/sheet/sheet-close.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-content.svelte +70 -0
- package/dist/components/ui/sheet/sheet-content.svelte.d.ts +37 -0
- package/dist/components/ui/sheet/sheet-description.svelte +20 -0
- package/dist/components/ui/sheet/sheet-description.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-footer.svelte +23 -0
- package/dist/components/ui/sheet/sheet-footer.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-header.svelte +21 -0
- package/dist/components/ui/sheet/sheet-header.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-overlay.svelte +20 -0
- package/dist/components/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-portal.svelte +7 -0
- package/dist/components/ui/sheet/sheet-portal.svelte.d.ts +3 -0
- package/dist/components/ui/sheet/sheet-title.svelte +21 -0
- package/dist/components/ui/sheet/sheet-title.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-trigger.svelte +11 -0
- package/dist/components/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet.svelte +7 -0
- package/dist/components/ui/sheet/sheet.svelte.d.ts +3 -0
- package/dist/components/ui/skeleton/skeleton.svelte +1 -1
- package/dist/styles.css +8 -0
- package/package.json +4 -2
- package/registry/component-map.json +58 -5
- package/registry/component-map.md +17 -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** (
|
|
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`,
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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). |
|
|
@@ -119,6 +127,7 @@ rather than rebuilding it:
|
|
|
119
127
|
| `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
|
|
120
128
|
| `ledger` | The one ledger every module draws: dated rows of money, coins or units, two lines a row, grouped by the viewer's period, the viewer's columns and running balance. See [The ledger](#the-ledger). |
|
|
121
129
|
| `resize-handle` | The drag edge of a side panel: pointer, arrow keys, double-click reset, remembered width, ceiling measured against the content beside it. AppShell's rail uses it; an app with its own `<aside>` renders `<ResizeHandle bind:width storageKey="…" />` as the aside's last child (positioned) and sets the aside's width from `width`. |
|
|
130
|
+
| `graph-canvas` | A live force-directed graph of entities and their relations: pan, zoom, pinch, find, a legend that filters, a selected node's neighbourhood lit and framed, its card. See [A graph of relations](#a-graph-of-relations). |
|
|
122
131
|
|
|
123
132
|
**The application shell** (`app-shell`, `command-palette`). Page chrome is not
|
|
124
133
|
what makes an app feel like an app either — the shell is. Five household
|
|
@@ -533,9 +542,13 @@ Two things worth knowing before you set it:
|
|
|
533
542
|
replaces. An app running `padded={false}` and its own page padding gets the cap
|
|
534
543
|
flush, which is usually what it wanted.
|
|
535
544
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
539
552
|
|
|
540
553
|
#### A block inside the page: `.ds-measure`
|
|
541
554
|
|
|
@@ -561,6 +574,77 @@ paragraphs ~207px away from their own label. Both halves are gated in
|
|
|
561
574
|
on the page's own left edge, and it follows a retune of
|
|
562
575
|
`--ds-shell-measure-prose`.
|
|
563
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
|
+
|
|
564
648
|
### The content texture
|
|
565
649
|
|
|
566
650
|
The house atmosphere — a faint dot-grid floor with a soft accent vignette in the
|
|
@@ -1051,6 +1135,46 @@ the page opened; that item's document stands beside it (`?surface=record`).
|
|
|
1051
1135
|
into the same pane opens on its first page, fitted; so does one the column
|
|
1052
1136
|
moves beside itself as the row crosses 1250px.
|
|
1053
1137
|
|
|
1138
|
+
## A graph of relations
|
|
1139
|
+
|
|
1140
|
+
`GraphCanvas` (`@poodle64/ui/graph-canvas`) draws people, organisations, sources, any entities, as nodes and their relations as edges, laid out live by d3-force and painted on one canvas. It fills its container, so the page gives it a height.
|
|
1141
|
+
|
|
1142
|
+
```svelte
|
|
1143
|
+
<script lang="ts">
|
|
1144
|
+
import GraphCanvas, { type GraphKind } from '@poodle64/ui/graph-canvas';
|
|
1145
|
+
const kinds: GraphKind[] = [
|
|
1146
|
+
{ id: 'person', label: 'People', color: 'var(--chart-1)' },
|
|
1147
|
+
{ id: 'organisation', label: 'Organisations', color: 'var(--chart-2)', shape: 'square' },
|
|
1148
|
+
{ id: 'source', label: 'Sources', color: 'var(--chart-3)', shape: 'diamond' }
|
|
1149
|
+
];
|
|
1150
|
+
let graph = $state.raw({ nodes: [], edges: [] });
|
|
1151
|
+
let selected = $state<string | null>(null);
|
|
1152
|
+
</script>
|
|
1153
|
+
|
|
1154
|
+
<div class="h-[70vh]">
|
|
1155
|
+
<GraphCanvas nodes={graph.nodes} edges={graph.edges} {kinds} directed bind:selected
|
|
1156
|
+
onactivate={(n) => goto(`/entities/${n.id}`)} />
|
|
1157
|
+
</div>
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
| Prop | |
|
|
1161
|
+
| --- | --- |
|
|
1162
|
+
| `nodes` | `{ id, label, kind, description?, emphasis?, size? }[]`. Extra fields ride along to the callbacks and the `detail` snippet, typed. |
|
|
1163
|
+
| `edges` | `{ source, target, label? }[]` by node id; an edge naming an unknown id is dropped. Parallel edges fan out. |
|
|
1164
|
+
| `kinds` | Legend order, `label`, `color` (any CSS colour; default the primary) and `shape` (`circle`, `square`, `diamond`, `triangle`). The package owns no categorical palette, as with `Sparkline`. |
|
|
1165
|
+
| `selected`, `hiddenKinds` | Bindable: the selected id, and the kinds the legend has switched off. |
|
|
1166
|
+
| `directed` | Arrowheads at each target, and the card names a relation pointing here as "Director of this". |
|
|
1167
|
+
| `onselect(node \| null)`, `onactivate(node)` | A click, find or card pick; a double-click, Enter or the card's `activateLabel` button. |
|
|
1168
|
+
| `detail` | `Snippet<[node, neighbours]>` under the card's title, for the app's own actions. |
|
|
1169
|
+
| `search`, `label` | The find field (default on); the accessible name. |
|
|
1170
|
+
|
|
1171
|
+
Exports on the instance (`bind:this`): `fit()`, `focus(id)` (select and frame the neighbourhood), `locate(id)` (where it is drawn, in px) and `relayout()`.
|
|
1172
|
+
|
|
1173
|
+
- **Live data.** Pass `$state.raw` and assign a new array to change it. Every surviving node keeps its place, a newcomer starts beside the nodes it joins, and the layout warms only as much as the change needs. A deep `$state` proxy works, but every paint then reads through it.
|
|
1174
|
+
- **Themed where it is placed.** Each colour is resolved against the component's own element, so inside an element's shadow root it takes the host page's palette, and it follows the mode class, an element host's `data-dark`, a palette swap and the OS scheme without a reload. Nothing portals out.
|
|
1175
|
+
- **Size.** The layout ticks within a time budget per frame, edges and nodes are culled and stroked as a few batched paths, and names are placed in screen space against what is already drawn, a quota by area at rest and every neighbour under a focus. A few thousand nodes is the design point; well past ten thousand, a WebGL renderer is the better tool.
|
|
1176
|
+
- **Load it lazily.** It brings d3-force and a canvas engine, so a route imports it with a dynamic `import()` (`sveltekit-quality.md`).
|
|
1177
|
+
|
|
1054
1178
|
## Hand-written forms
|
|
1055
1179
|
|
|
1056
1180
|
The shadcn-svelte Formsnap wrapper set, over `sveltekit-superforms`:
|
|
@@ -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
|
|
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
|
>
|
|
@@ -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
|
|
201
|
+
{#if held.length}
|
|
197
202
|
<div class="flex flex-wrap gap-1 px-2 pb-2">
|
|
198
|
-
{#each
|
|
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. `
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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. `
|
|
16
|
-
*
|
|
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
|
|
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,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;
|