@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.
- package/README.md +94 -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/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 +1 -1
- package/registry/component-map.json +43 -4
- package/registry/component-map.md +9 -4
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/** One heading of the body, as the contents list shows it. */
|
|
3
|
+
export type DocumentReaderHeading = { id: string; text: string; level: 2 | 3 };
|
|
4
|
+
|
|
5
|
+
/** A heading's id from its text: lower case, words joined by hyphens. */
|
|
6
|
+
export function slugify(text: string): string {
|
|
7
|
+
return (
|
|
8
|
+
text
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.normalize('NFKD')
|
|
11
|
+
.replace(/[̀-ͯ]/g, '')
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '') || 'section'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
/**
|
|
20
|
+
* A document to read: its title and trail, the body at a reading measure,
|
|
21
|
+
* and a contents list built from the body's own headings.
|
|
22
|
+
*
|
|
23
|
+
* One app had built this for four routes and read its library documents
|
|
24
|
+
* through a second shell, a DocumentDetail with the body in a box under it.
|
|
25
|
+
* DocumentDetail stays the catalogue record of a document; when there is a
|
|
26
|
+
* body to read, this is where it is read, and the record's facts go in
|
|
27
|
+
* `aside` or `meta`.
|
|
28
|
+
*
|
|
29
|
+
* The body is HTML the app has already rendered and sanitised (`html`), or
|
|
30
|
+
* markup (`children`). The package styles it with `.ds-prose` and never
|
|
31
|
+
* renders markdown itself, for the reason `.ds-prose` gives: sanitising is
|
|
32
|
+
* a security boundary the package cannot see the inputs of.
|
|
33
|
+
*
|
|
34
|
+
* The contents list is the body's `h2` and `h3`. A heading with no id is
|
|
35
|
+
* given one from its text, so an app's markdown pipeline needs no heading-id
|
|
36
|
+
* plugin. A link scrolls to its heading directly rather than through the
|
|
37
|
+
* URL fragment, because a fragment cannot find an element inside a shadow
|
|
38
|
+
* root, and this reader appears inside elements too.
|
|
39
|
+
*
|
|
40
|
+
* It centres itself and carries its own measure, so its route's `measure`
|
|
41
|
+
* only needs to leave room: `page` or wider shows the contents beside the
|
|
42
|
+
* body, and the shift from beside to above is a container query on the
|
|
43
|
+
* reader's own width, not the window's.
|
|
44
|
+
*/
|
|
45
|
+
import { untrack, type Snippet } from 'svelte';
|
|
46
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
47
|
+
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
48
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
49
|
+
import Button from '../button/button.svelte';
|
|
50
|
+
import PageHeader from '../page-header/page-header.svelte';
|
|
51
|
+
import type { BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
|
|
52
|
+
import ContentSkeleton from '../content-skeleton/content-skeleton.svelte';
|
|
53
|
+
import ErrorState from '../error-state/error-state.svelte';
|
|
54
|
+
import LoadingState from '../loading-state/loading-state.svelte';
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
title,
|
|
58
|
+
subtitle,
|
|
59
|
+
breadcrumbs,
|
|
60
|
+
meta,
|
|
61
|
+
actions,
|
|
62
|
+
html,
|
|
63
|
+
children,
|
|
64
|
+
contents = true,
|
|
65
|
+
contentsLabel = 'On this page',
|
|
66
|
+
aside,
|
|
67
|
+
footer,
|
|
68
|
+
loading = false,
|
|
69
|
+
error = null,
|
|
70
|
+
onRetry,
|
|
71
|
+
ref = $bindable(null),
|
|
72
|
+
class: className,
|
|
73
|
+
...restProps
|
|
74
|
+
}: Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, 'title' | 'children'> & {
|
|
75
|
+
/** The document's title, as the page's heading. */
|
|
76
|
+
title?: string;
|
|
77
|
+
subtitle?: string;
|
|
78
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
79
|
+
/** Facts about the document under its title: a date, a reading time, a status. */
|
|
80
|
+
meta?: Snippet;
|
|
81
|
+
actions?: Snippet;
|
|
82
|
+
/** The body, rendered and sanitised by the app. */
|
|
83
|
+
html?: string;
|
|
84
|
+
/** The body as markup, instead of `html`. */
|
|
85
|
+
children?: Snippet;
|
|
86
|
+
/** Show the contents list once the body has two headings or more. */
|
|
87
|
+
contents?: boolean;
|
|
88
|
+
contentsLabel?: string;
|
|
89
|
+
/** More for the side column, under the contents: the document's catalogue facts. */
|
|
90
|
+
aside?: Snippet;
|
|
91
|
+
/** After the body: what to read next, a completion control, related documents. */
|
|
92
|
+
footer?: Snippet;
|
|
93
|
+
/** The body is loading. With a `title` the header stands and the body is a skeleton. */
|
|
94
|
+
loading?: boolean;
|
|
95
|
+
error?: string | null;
|
|
96
|
+
onRetry?: () => void;
|
|
97
|
+
} = $props();
|
|
98
|
+
|
|
99
|
+
let article = $state<HTMLElement | null>(null);
|
|
100
|
+
let disclosure = $state<HTMLDetailsElement | null>(null);
|
|
101
|
+
let headings = $state<DocumentReaderHeading[]>([]);
|
|
102
|
+
let read = $state(false);
|
|
103
|
+
let active = $state<string | null>(null);
|
|
104
|
+
|
|
105
|
+
const showContents = $derived(contents && headings.length >= 2);
|
|
106
|
+
// The column is held while the body loads and until its headings are read
|
|
107
|
+
// (before the first paint), so a structured document never moves sideways
|
|
108
|
+
// as it arrives; one with fewer than two headings gives the room back.
|
|
109
|
+
const side = $derived(!!aside || (contents && (loading || !read || headings.length >= 2)));
|
|
110
|
+
|
|
111
|
+
/** Give every h2 and h3 an id, and read the list. */
|
|
112
|
+
function readHeadings(root: HTMLElement): DocumentReaderHeading[] {
|
|
113
|
+
const scope = root.getRootNode() as Document | ShadowRoot;
|
|
114
|
+
const taken = new Set<string>();
|
|
115
|
+
const found: DocumentReaderHeading[] = [];
|
|
116
|
+
const free = (id: string, el: HTMLElement) =>
|
|
117
|
+
!taken.has(id) && [null, el].includes(scope.getElementById(id) as HTMLElement | null);
|
|
118
|
+
for (const el of root.querySelectorAll<HTMLElement>('h2, h3')) {
|
|
119
|
+
let id = el.id;
|
|
120
|
+
if (!id || taken.has(id)) {
|
|
121
|
+
const base = slugify(el.textContent ?? '');
|
|
122
|
+
id = base;
|
|
123
|
+
for (let n = 2; !free(id, el); n++) id = `${base}-${n}`;
|
|
124
|
+
el.id = id;
|
|
125
|
+
}
|
|
126
|
+
taken.add(id);
|
|
127
|
+
found.push({ id, text: (el.textContent ?? '').trim(), level: el.tagName === 'H2' ? 2 : 3 });
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const same = (a: DocumentReaderHeading[], b: DocumentReaderHeading[]) =>
|
|
133
|
+
a.length === b.length &&
|
|
134
|
+
a.every((h, i) => h.id === b[i]?.id && h.text === b[i]?.text && h.level === b[i]?.level);
|
|
135
|
+
|
|
136
|
+
// A link that named a heading: the router scrolled before the body existed,
|
|
137
|
+
// so the reader scrolls once the heading does. A fragment that is not valid
|
|
138
|
+
// percent-encoding names nothing.
|
|
139
|
+
let hashSought = false;
|
|
140
|
+
function seekHash(root: HTMLElement) {
|
|
141
|
+
if (hashSought) return;
|
|
142
|
+
let id = '';
|
|
143
|
+
try {
|
|
144
|
+
id = decodeURIComponent(window.location.hash.slice(1));
|
|
145
|
+
} catch {
|
|
146
|
+
hashSought = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const el = id ? root.querySelector<HTMLElement>(`#${CSS.escape(id)}`) : null;
|
|
150
|
+
if (el) el.scrollIntoView({ block: 'start' });
|
|
151
|
+
hashSought = !id || !!el;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The body changes when `html` does, or when the app's own markup does; a
|
|
155
|
+
// MutationObserver sees both. Attributes are not observed, so giving a
|
|
156
|
+
// heading its id does not re-run this, and a change that leaves the
|
|
157
|
+
// headings as they were leaves the list alone.
|
|
158
|
+
$effect(() => {
|
|
159
|
+
const root = article;
|
|
160
|
+
if (!root) return;
|
|
161
|
+
const update = () => {
|
|
162
|
+
const found = readHeadings(root);
|
|
163
|
+
if (!same(found, untrack(() => headings))) headings = found;
|
|
164
|
+
read = true;
|
|
165
|
+
seekHash(root);
|
|
166
|
+
};
|
|
167
|
+
update();
|
|
168
|
+
const observer = new MutationObserver(update);
|
|
169
|
+
observer.observe(root, { childList: true, subtree: true, characterData: true });
|
|
170
|
+
return () => observer.disconnect();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// The heading being read: the last one whose top has passed a line a little
|
|
174
|
+
// below the top of whatever scrolls. Scroll does not bubble but is captured
|
|
175
|
+
// at the window from any scroller in the page, and at the shadow root from
|
|
176
|
+
// one inside an element, which the window never hears. A link's own jump
|
|
177
|
+
// holds its heading for the length of the scroll it starts.
|
|
178
|
+
const READING_LINE = 96;
|
|
179
|
+
let heldUntil = 0;
|
|
180
|
+
$effect(() => {
|
|
181
|
+
const list = headings;
|
|
182
|
+
const root = article;
|
|
183
|
+
if (!root || list.length < 2) return;
|
|
184
|
+
const scopes: EventTarget[] = [window];
|
|
185
|
+
const shadow = root.getRootNode();
|
|
186
|
+
if (shadow instanceof ShadowRoot) scopes.push(shadow);
|
|
187
|
+
let frame = 0;
|
|
188
|
+
const update = () => {
|
|
189
|
+
frame = 0;
|
|
190
|
+
if (performance.now() < heldUntil) return;
|
|
191
|
+
let current: string | null = list[0]?.id ?? null;
|
|
192
|
+
for (const { id } of list) {
|
|
193
|
+
const el = root.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
|
194
|
+
if (el && el.getBoundingClientRect().top <= READING_LINE) current = id;
|
|
195
|
+
else break;
|
|
196
|
+
}
|
|
197
|
+
active = current;
|
|
198
|
+
};
|
|
199
|
+
const onScroll = () => {
|
|
200
|
+
if (!frame) frame = requestAnimationFrame(update);
|
|
201
|
+
};
|
|
202
|
+
update();
|
|
203
|
+
for (const scope of scopes) scope.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
204
|
+
return () => {
|
|
205
|
+
for (const scope of scopes) scope.removeEventListener('scroll', onScroll, { capture: true });
|
|
206
|
+
if (frame) cancelAnimationFrame(frame);
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function go(event: MouseEvent, id: string) {
|
|
211
|
+
const el = article?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
|
212
|
+
if (!el) return;
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
215
|
+
heldUntil = performance.now() + (still ? 0 : 800);
|
|
216
|
+
if (disclosure) disclosure.open = false;
|
|
217
|
+
el.scrollIntoView({ block: 'start', behavior: still ? 'auto' : 'smooth' });
|
|
218
|
+
// Move focus with the view, so the next Tab continues from the heading.
|
|
219
|
+
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
|
|
220
|
+
el.focus({ preventScroll: true });
|
|
221
|
+
active = id;
|
|
222
|
+
}
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
{#snippet contentsList()}
|
|
226
|
+
<nav aria-label={contentsLabel}>
|
|
227
|
+
<ul class="border-border flex flex-col border-l text-sm">
|
|
228
|
+
{#each headings as heading (heading.id)}
|
|
229
|
+
<li>
|
|
230
|
+
<a
|
|
231
|
+
href={`#${heading.id}`}
|
|
232
|
+
onclick={(event) => go(event, heading.id)}
|
|
233
|
+
aria-current={active === heading.id ? 'location' : undefined}
|
|
234
|
+
class={cn(
|
|
235
|
+
'focus-visible:ring-ring/50 -ml-px block border-l-2 border-transparent py-1 pr-2 leading-snug outline-none focus-visible:ring-3',
|
|
236
|
+
heading.level === 2 ? 'pl-3' : 'pl-6',
|
|
237
|
+
active === heading.id
|
|
238
|
+
? 'border-primary text-foreground font-medium'
|
|
239
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
240
|
+
)}>{heading.text}</a
|
|
241
|
+
>
|
|
242
|
+
</li>
|
|
243
|
+
{/each}
|
|
244
|
+
</ul>
|
|
245
|
+
</nav>
|
|
246
|
+
{/snippet}
|
|
247
|
+
|
|
248
|
+
<div
|
|
249
|
+
bind:this={ref}
|
|
250
|
+
data-slot="document-reader"
|
|
251
|
+
class={cn('@container w-full text-base', className)}
|
|
252
|
+
{...restProps}
|
|
253
|
+
>
|
|
254
|
+
{#if error}
|
|
255
|
+
<ErrorState message={error}>
|
|
256
|
+
{#snippet action()}
|
|
257
|
+
{#if onRetry}
|
|
258
|
+
<Button variant="outline" onclick={onRetry}>Retry</Button>
|
|
259
|
+
{/if}
|
|
260
|
+
{/snippet}
|
|
261
|
+
</ErrorState>
|
|
262
|
+
{:else if loading && !title}
|
|
263
|
+
<LoadingState message="Loading the document…" />
|
|
264
|
+
{:else}
|
|
265
|
+
<div
|
|
266
|
+
class={cn(
|
|
267
|
+
'mx-auto w-full max-w-(--ds-shell-measure-prose)',
|
|
268
|
+
side && '@5xl:max-w-[calc(var(--ds-shell-measure-prose)+16.5rem)]'
|
|
269
|
+
)}
|
|
270
|
+
>
|
|
271
|
+
{#if title || breadcrumbs?.length || meta || actions}
|
|
272
|
+
<PageHeader {title} {subtitle} {breadcrumbs} {meta} {actions} />
|
|
273
|
+
{/if}
|
|
274
|
+
{#if loading}
|
|
275
|
+
<ContentSkeleton shape="prose" label="Loading the document…" />
|
|
276
|
+
{:else}
|
|
277
|
+
<!-- One column, then two from 64rem of reader. The side column is
|
|
278
|
+
rendered once: below 64rem it follows the body and shows only
|
|
279
|
+
`aside`, since the contents fold into the disclosure above. -->
|
|
280
|
+
<div class="flex flex-col @5xl:flex-row @5xl:items-start @5xl:gap-10">
|
|
281
|
+
<div class="min-w-0 flex-1">
|
|
282
|
+
{#if showContents}
|
|
283
|
+
<details
|
|
284
|
+
bind:this={disclosure}
|
|
285
|
+
class="group bg-card border-border ds-edge mb-6 rounded-lg border @5xl:hidden"
|
|
286
|
+
>
|
|
287
|
+
<summary
|
|
288
|
+
class="focus-visible:ring-ring/50 flex cursor-pointer list-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium outline-none focus-visible:ring-3 [&::-webkit-details-marker]:hidden"
|
|
289
|
+
>
|
|
290
|
+
<ChevronRight
|
|
291
|
+
class="text-muted-foreground size-4 transition-transform group-open:rotate-90"
|
|
292
|
+
aria-hidden="true"
|
|
293
|
+
/>
|
|
294
|
+
{contentsLabel}
|
|
295
|
+
</summary>
|
|
296
|
+
<div class="px-3 pb-3">{@render contentsList()}</div>
|
|
297
|
+
</details>
|
|
298
|
+
{/if}
|
|
299
|
+
<article
|
|
300
|
+
bind:this={article}
|
|
301
|
+
class="ds-prose ds-measure [&_:is(h2,h3)]:scroll-mt-4 [&_:is(h2,h3)]:outline-none"
|
|
302
|
+
data-measure="prose"
|
|
303
|
+
>
|
|
304
|
+
{#if html !== undefined}
|
|
305
|
+
{@html html}
|
|
306
|
+
{:else}
|
|
307
|
+
{@render children?.()}
|
|
308
|
+
{/if}
|
|
309
|
+
</article>
|
|
310
|
+
</div>
|
|
311
|
+
{#if side}
|
|
312
|
+
<div
|
|
313
|
+
data-slot="document-reader-side"
|
|
314
|
+
class="flex flex-col gap-8 @5xl:sticky @5xl:top-4 @5xl:max-h-[calc(100dvh-6rem)] @5xl:w-56 @5xl:shrink-0 @5xl:overflow-y-auto"
|
|
315
|
+
>
|
|
316
|
+
{#if showContents}
|
|
317
|
+
<div class="hidden flex-col gap-2 @5xl:flex">
|
|
318
|
+
<p class="text-sm font-medium" aria-hidden="true">{contentsLabel}</p>
|
|
319
|
+
{@render contentsList()}
|
|
320
|
+
</div>
|
|
321
|
+
{/if}
|
|
322
|
+
{#if aside}
|
|
323
|
+
<div class="mt-10 @5xl:mt-0">{@render aside()}</div>
|
|
324
|
+
{/if}
|
|
325
|
+
</div>
|
|
326
|
+
{/if}
|
|
327
|
+
</div>
|
|
328
|
+
{#if footer}
|
|
329
|
+
<div class="ds-measure border-border mt-12 border-t pt-6" data-measure="prose">
|
|
330
|
+
{@render footer()}
|
|
331
|
+
</div>
|
|
332
|
+
{/if}
|
|
333
|
+
{/if}
|
|
334
|
+
</div>
|
|
335
|
+
{/if}
|
|
336
|
+
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** One heading of the body, as the contents list shows it. */
|
|
2
|
+
export type DocumentReaderHeading = {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
level: 2 | 3;
|
|
6
|
+
};
|
|
7
|
+
/** A heading's id from its text: lower case, words joined by hyphens. */
|
|
8
|
+
export declare function slugify(text: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* A document to read: its title and trail, the body at a reading measure,
|
|
11
|
+
* and a contents list built from the body's own headings.
|
|
12
|
+
*
|
|
13
|
+
* One app had built this for four routes and read its library documents
|
|
14
|
+
* through a second shell, a DocumentDetail with the body in a box under it.
|
|
15
|
+
* DocumentDetail stays the catalogue record of a document; when there is a
|
|
16
|
+
* body to read, this is where it is read, and the record's facts go in
|
|
17
|
+
* `aside` or `meta`.
|
|
18
|
+
*
|
|
19
|
+
* The body is HTML the app has already rendered and sanitised (`html`), or
|
|
20
|
+
* markup (`children`). The package styles it with `.ds-prose` and never
|
|
21
|
+
* renders markdown itself, for the reason `.ds-prose` gives: sanitising is
|
|
22
|
+
* a security boundary the package cannot see the inputs of.
|
|
23
|
+
*
|
|
24
|
+
* The contents list is the body's `h2` and `h3`. A heading with no id is
|
|
25
|
+
* given one from its text, so an app's markdown pipeline needs no heading-id
|
|
26
|
+
* plugin. A link scrolls to its heading directly rather than through the
|
|
27
|
+
* URL fragment, because a fragment cannot find an element inside a shadow
|
|
28
|
+
* root, and this reader appears inside elements too.
|
|
29
|
+
*
|
|
30
|
+
* It centres itself and carries its own measure, so its route's `measure`
|
|
31
|
+
* only needs to leave room: `page` or wider shows the contents beside the
|
|
32
|
+
* body, and the shift from beside to above is a container query on the
|
|
33
|
+
* reader's own width, not the window's.
|
|
34
|
+
*/
|
|
35
|
+
import { type Snippet } from 'svelte';
|
|
36
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
37
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
38
|
+
import type { BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
|
|
39
|
+
type $$ComponentProps = Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, 'title' | 'children'> & {
|
|
40
|
+
/** The document's title, as the page's heading. */
|
|
41
|
+
title?: string;
|
|
42
|
+
subtitle?: string;
|
|
43
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
44
|
+
/** Facts about the document under its title: a date, a reading time, a status. */
|
|
45
|
+
meta?: Snippet;
|
|
46
|
+
actions?: Snippet;
|
|
47
|
+
/** The body, rendered and sanitised by the app. */
|
|
48
|
+
html?: string;
|
|
49
|
+
/** The body as markup, instead of `html`. */
|
|
50
|
+
children?: Snippet;
|
|
51
|
+
/** Show the contents list once the body has two headings or more. */
|
|
52
|
+
contents?: boolean;
|
|
53
|
+
contentsLabel?: string;
|
|
54
|
+
/** More for the side column, under the contents: the document's catalogue facts. */
|
|
55
|
+
aside?: Snippet;
|
|
56
|
+
/** After the body: what to read next, a completion control, related documents. */
|
|
57
|
+
footer?: Snippet;
|
|
58
|
+
/** The body is loading. With a `title` the header stands and the body is a skeleton. */
|
|
59
|
+
loading?: boolean;
|
|
60
|
+
error?: string | null;
|
|
61
|
+
onRetry?: () => void;
|
|
62
|
+
};
|
|
63
|
+
declare const DocumentReader: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
64
|
+
type DocumentReader = ReturnType<typeof DocumentReader>;
|
|
65
|
+
export default DocumentReader;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A route's page: its header, sections and panels stacked at the one page
|
|
4
|
+
* rhythm. Pages had written that wrapper per file (`space-y-6`, `space-y-10`,
|
|
5
|
+
* `gap-4`, `gap-5`, `gap-10` in one app; a local PageContainer behind 56
|
|
6
|
+
* call sites in another).
|
|
7
|
+
*
|
|
8
|
+
* Width is not this component's: AppShell caps the content box from the
|
|
9
|
+
* route's `measure`, which a route declares in its load data. The container
|
|
10
|
+
* fills that box and grows to its height, so a page holding a board or a
|
|
11
|
+
* table can scroll inside itself.
|
|
12
|
+
*
|
|
13
|
+
* PageHeader carries its own bottom margin for pages written without this;
|
|
14
|
+
* inside it the gap alone spaces the header, so the header sits at the same
|
|
15
|
+
* distance either way.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
19
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
children,
|
|
23
|
+
ref = $bindable(null),
|
|
24
|
+
class: className,
|
|
25
|
+
...restProps
|
|
26
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children: Snippet } = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div
|
|
30
|
+
bind:this={ref}
|
|
31
|
+
data-slot="page-container"
|
|
32
|
+
class={cn(
|
|
33
|
+
'flex min-h-0 w-full min-w-0 flex-1 flex-col gap-6 *:data-[slot=page-header]:mb-0',
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
{...restProps}
|
|
37
|
+
>
|
|
38
|
+
{@render children()}
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route's page: its header, sections and panels stacked at the one page
|
|
3
|
+
* rhythm. Pages had written that wrapper per file (`space-y-6`, `space-y-10`,
|
|
4
|
+
* `gap-4`, `gap-5`, `gap-10` in one app; a local PageContainer behind 56
|
|
5
|
+
* call sites in another).
|
|
6
|
+
*
|
|
7
|
+
* Width is not this component's: AppShell caps the content box from the
|
|
8
|
+
* route's `measure`, which a route declares in its load data. The container
|
|
9
|
+
* fills that box and grows to its height, so a page holding a board or a
|
|
10
|
+
* table can scroll inside itself.
|
|
11
|
+
*
|
|
12
|
+
* PageHeader carries its own bottom margin for pages written without this;
|
|
13
|
+
* inside it the gap alone spaces the header, so the header sits at the same
|
|
14
|
+
* distance either way.
|
|
15
|
+
*/
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
18
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
19
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
20
|
+
children: Snippet;
|
|
21
|
+
};
|
|
22
|
+
declare const PageContainer: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
23
|
+
type PageContainer = ReturnType<typeof PageContainer>;
|
|
24
|
+
export default PageContainer;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
4
|
import { cn, type WithElementRef } from '../../../utils.js';
|
|
5
5
|
import InfoTip from '../info-tip/info-tip.svelte';
|
|
6
|
+
import Breadcrumbs, { type BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The one page-title pattern for every route. `subtitle` is a short, single
|
|
@@ -23,10 +24,11 @@
|
|
|
23
24
|
* the drift this package exists to end. One component, one spacing rhythm,
|
|
24
25
|
* two shapes.
|
|
25
26
|
*
|
|
26
|
-
* The trail
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* The trail is a list of steps (`BreadcrumbItem[]`), drawn by Breadcrumbs.
|
|
28
|
+
* A snippet is still accepted for a trail that is not a list of links (a
|
|
29
|
+
* record switcher in the trail's place), but a list is the default: three
|
|
30
|
+
* apps wrote the same chevron trail by hand, one behind a 28-call-site
|
|
31
|
+
* wrapper that existed only to turn this snippet into an array.
|
|
30
32
|
*
|
|
31
33
|
* `icon` and `meta` are snippets for the same reason and earn their place the
|
|
32
34
|
* same way `breadcrumbs` did: three apps had hand-rolled the meta row and two
|
|
@@ -50,8 +52,8 @@
|
|
|
50
52
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
51
53
|
/** A short kicker above the title. Use this OR `breadcrumbs`, not both. */
|
|
52
54
|
eyebrow?: string;
|
|
53
|
-
/** The trail above the title
|
|
54
|
-
breadcrumbs?: Snippet;
|
|
55
|
+
/** The trail above the title: its steps, or a snippet for a trail that is not links. */
|
|
56
|
+
breadcrumbs?: BreadcrumbItem[] | Snippet;
|
|
55
57
|
/** The glyph alone. The tinted square around it is this component's. */
|
|
56
58
|
icon?: Snippet;
|
|
57
59
|
/** Omit for a header that is a breadcrumb bar with actions. */
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
|
|
67
69
|
<div
|
|
68
70
|
bind:this={ref}
|
|
71
|
+
data-slot="page-header"
|
|
69
72
|
class={cn('mb-6 flex flex-wrap items-start justify-between gap-4', className)}
|
|
70
73
|
{...restProps}
|
|
71
74
|
>
|
|
@@ -83,10 +86,12 @@
|
|
|
83
86
|
</span>
|
|
84
87
|
{/if}
|
|
85
88
|
<div class="min-w-0">
|
|
86
|
-
{#if breadcrumbs}
|
|
89
|
+
{#if typeof breadcrumbs === 'function'}
|
|
87
90
|
<div class="text-muted-foreground mb-1.5 flex min-w-0 items-center text-sm">
|
|
88
91
|
{@render breadcrumbs()}
|
|
89
92
|
</div>
|
|
93
|
+
{:else if breadcrumbs?.length}
|
|
94
|
+
<Breadcrumbs items={breadcrumbs} class="mb-1.5" />
|
|
90
95
|
{/if}
|
|
91
96
|
{#if eyebrow}
|
|
92
97
|
<div class="text-primary text-2xs tracking-eyebrow mb-1 font-medium uppercase">
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
import { type WithElementRef } from '../../../utils.js';
|
|
4
|
+
import { type BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
|
|
4
5
|
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
5
6
|
/** A short kicker above the title. Use this OR `breadcrumbs`, not both. */
|
|
6
7
|
eyebrow?: string;
|
|
7
|
-
/** The trail above the title
|
|
8
|
-
breadcrumbs?: Snippet;
|
|
8
|
+
/** The trail above the title: its steps, or a snippet for a trail that is not links. */
|
|
9
|
+
breadcrumbs?: BreadcrumbItem[] | Snippet;
|
|
9
10
|
/** The glyph alone. The tinted square around it is this component's. */
|
|
10
11
|
icon?: Snippet;
|
|
11
12
|
/** Omit for a header that is a breadcrumb bar with actions. */
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A section of a page, under its PageHeader: a heading, an optional line of
|
|
4
|
+
* explanation, trailing actions, and the section's content at one distance
|
|
5
|
+
* below them.
|
|
6
|
+
*
|
|
7
|
+
* One app had five recipes for this one role, from an 11px uppercase kicker
|
|
8
|
+
* to a 20px display heading, and each page spaced its content from the
|
|
9
|
+
* heading differently. The heading and the gap under it are this
|
|
10
|
+
* component's, because those are the halves that drift.
|
|
11
|
+
*
|
|
12
|
+
* Level 2 is a section, in the display face one step under the page title.
|
|
13
|
+
* Level 3 is a subsection, in the body face at a Panel title's size, so a
|
|
14
|
+
* titled card and a subsection read as the same rank. There is no uppercase
|
|
15
|
+
* kicker form: a small caps label above content is the column head of a
|
|
16
|
+
* table or a nav group, never a heading.
|
|
17
|
+
*/
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
20
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
title,
|
|
24
|
+
level = 2,
|
|
25
|
+
description,
|
|
26
|
+
count,
|
|
27
|
+
actions,
|
|
28
|
+
children,
|
|
29
|
+
id,
|
|
30
|
+
ref = $bindable(null),
|
|
31
|
+
class: className,
|
|
32
|
+
...restProps
|
|
33
|
+
}: Omit<WithElementRef<HTMLAttributes<HTMLElement>>, 'title'> & {
|
|
34
|
+
title: string;
|
|
35
|
+
/** 2, a section of the page; 3, a section within one. */
|
|
36
|
+
level?: 2 | 3;
|
|
37
|
+
/** A sentence or two under the heading. It wraps. */
|
|
38
|
+
description?: string;
|
|
39
|
+
/** How many things the section holds, set beside the heading. */
|
|
40
|
+
count?: number;
|
|
41
|
+
/** Controls for the section, trailing the heading. */
|
|
42
|
+
actions?: Snippet;
|
|
43
|
+
children?: Snippet;
|
|
44
|
+
/** The heading's id, for a link to the section. One is generated otherwise. */
|
|
45
|
+
id?: string;
|
|
46
|
+
} = $props();
|
|
47
|
+
|
|
48
|
+
const fallbackId = $props.id();
|
|
49
|
+
const headingId = $derived(id ?? `section-${fallbackId}`);
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<section
|
|
53
|
+
bind:this={ref}
|
|
54
|
+
aria-labelledby={headingId}
|
|
55
|
+
data-slot="page-section"
|
|
56
|
+
class={cn('flex min-w-0 flex-col', level === 2 ? 'gap-4' : 'gap-3', className)}
|
|
57
|
+
{...restProps}
|
|
58
|
+
>
|
|
59
|
+
<!-- Actions share the heading's row, so on a phone they stay beside the
|
|
60
|
+
heading rather than dropping under the description. -->
|
|
61
|
+
<header class="flex min-w-0 flex-col gap-1">
|
|
62
|
+
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
|
|
63
|
+
<svelte:element
|
|
64
|
+
this={`h${level}`}
|
|
65
|
+
id={headingId}
|
|
66
|
+
class={cn(
|
|
67
|
+
'flex min-w-0 items-baseline gap-2 scroll-mt-4 font-semibold',
|
|
68
|
+
level === 2
|
|
69
|
+
? 'font-display text-section tracking-[var(--ds-font-display-tracking,-0.015em)]'
|
|
70
|
+
: 'text-body leading-tight tracking-tight'
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<span class="min-w-0">{title}</span>
|
|
74
|
+
{#if count !== undefined}
|
|
75
|
+
<span
|
|
76
|
+
class="text-muted-foreground font-sans text-sm font-normal tracking-normal tabular-nums"
|
|
77
|
+
>{count}</span
|
|
78
|
+
>
|
|
79
|
+
{/if}
|
|
80
|
+
</svelte:element>
|
|
81
|
+
{#if actions}
|
|
82
|
+
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
83
|
+
{@render actions()}
|
|
84
|
+
</div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
{#if description}
|
|
88
|
+
<p class="text-muted-foreground ds-measure text-sm" data-measure="prose">{description}</p>
|
|
89
|
+
{/if}
|
|
90
|
+
</header>
|
|
91
|
+
{@render children?.()}
|
|
92
|
+
</section>
|