@poodle64/ui 2026.8.10 → 2026.8.12

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 +180 -30
  2. package/dist/components/ui/app-shell/app-nav.svelte +12 -12
  3. package/dist/components/ui/app-shell/app-shell.svelte +14 -12
  4. package/dist/components/ui/app-shell/app-shell.svelte.d.ts +14 -6
  5. package/dist/components/ui/app-shell/types.d.ts +20 -3
  6. package/dist/components/ui/app-shell/types.js +29 -5
  7. package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
  8. package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
  9. package/dist/components/ui/collection-detail/index.d.ts +3 -0
  10. package/dist/components/ui/collection-detail/index.js +2 -0
  11. package/dist/components/ui/document-detail/document-detail.svelte +184 -0
  12. package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
  13. package/dist/components/ui/document-detail/index.d.ts +3 -0
  14. package/dist/components/ui/document-detail/index.js +2 -0
  15. package/dist/components/ui/library-browse/document-table.svelte +101 -0
  16. package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
  17. package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
  18. package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
  19. package/dist/components/ui/library-browse/index.d.ts +3 -0
  20. package/dist/components/ui/library-browse/index.js +2 -0
  21. package/dist/components/ui/library-browse/library-browse.svelte +235 -0
  22. package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
  23. package/dist/components/ui/library-browse/types.d.ts +101 -0
  24. package/dist/components/ui/library-browse/types.js +1 -0
  25. package/dist/components/ui/schema-form/context.d.ts +31 -0
  26. package/dist/components/ui/schema-form/context.js +10 -0
  27. package/dist/components/ui/schema-form/data.d.ts +42 -0
  28. package/dist/components/ui/schema-form/data.js +97 -0
  29. package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
  30. package/dist/components/ui/schema-form/dispatch.js +139 -0
  31. package/dist/components/ui/schema-form/index.d.ts +5 -0
  32. package/dist/components/ui/schema-form/index.js +5 -0
  33. package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
  34. package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
  35. package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
  36. package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
  37. package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
  38. package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
  39. package/dist/components/ui/schema-form/schema-form.svelte +153 -0
  40. package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
  41. package/dist/components/ui/schema-form/types.d.ts +61 -0
  42. package/dist/components/ui/schema-form/types.js +21 -0
  43. package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
  44. package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
  45. package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
  46. package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
  47. package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
  48. package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
  49. package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
  50. package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
  51. package/dist/components/ui/search-results/index.d.ts +3 -0
  52. package/dist/components/ui/search-results/index.js +2 -0
  53. package/dist/components/ui/search-results/search-results.svelte +183 -0
  54. package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
  55. package/package.json +2 -1
  56. package/registry/component-map.json +52 -3
  57. package/registry/component-map.md +20 -1
@@ -0,0 +1,169 @@
1
+ <script lang="ts">
2
+ /**
3
+ * CollectionDetail — one collection's surface: its identity (name, meta,
4
+ * state), an at-a-glance stat list, and the documents it holds.
5
+ *
6
+ * Fixed props over plain data (#30): the page fetches, maps its responses
7
+ * into the shared library vocabulary, and owns the pager offset. Anything
8
+ * app-specific — a config form, an operations tab, action buttons — comes
9
+ * in through `actions` (the identity panel's footer) and `children`
10
+ * (sections rendered after the documents), so the app extends the surface
11
+ * without forking it.
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+ import type { HTMLAttributes } from 'svelte/elements';
15
+ import { cn, type WithElementRef } from '../../../utils.js';
16
+ import Button from '../button/button.svelte';
17
+ import DetailPanel from '../detail-panel/detail-panel.svelte';
18
+ import Panel from '../panel/panel.svelte';
19
+ import StatList from '../stat-list/stat-list.svelte';
20
+ import type { StatItem } from '../stat-list/stat-list.svelte';
21
+ import EmptyState from '../empty-state/empty-state.svelte';
22
+ import ErrorState from '../error-state/error-state.svelte';
23
+ import LoadingState from '../loading-state/loading-state.svelte';
24
+ import DocumentTable from '../library-browse/document-table.svelte';
25
+ import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
26
+
27
+ let {
28
+ collection,
29
+ stats = [],
30
+ documents = [],
31
+ documentsTotal,
32
+ offset = 0,
33
+ limit = 25,
34
+ loading = false,
35
+ error = null,
36
+ documentsLoading = false,
37
+ documentsError = null,
38
+ emptyTitle = 'No documents in this collection yet',
39
+ emptyDescription,
40
+ notFoundTitle = 'Collection not found',
41
+ notFoundDescription,
42
+ onPageChange,
43
+ onRetry,
44
+ documentHref,
45
+ onOpenDocument,
46
+ actions,
47
+ children,
48
+ ref = $bindable(null),
49
+ class: className,
50
+ ...restProps
51
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
52
+ collection: LibraryCollection | null;
53
+ /** At-a-glance figures for the collection (document count, indexed, …). */
54
+ stats?: StatItem[];
55
+ documents?: LibraryDocument[];
56
+ /** Total documents across all pages; defaults to the rows given. */
57
+ documentsTotal?: number;
58
+ offset?: number;
59
+ limit?: number;
60
+ /** The collection itself is still loading. */
61
+ loading?: boolean;
62
+ error?: string | null;
63
+ /** The document list loads independently of the identity. */
64
+ documentsLoading?: boolean;
65
+ documentsError?: string | null;
66
+ emptyTitle?: string;
67
+ emptyDescription?: string;
68
+ notFoundTitle?: string;
69
+ notFoundDescription?: string;
70
+ onPageChange?: (offset: number) => void;
71
+ onRetry?: () => void;
72
+ documentHref?: (doc: LibraryDocument) => string;
73
+ onOpenDocument?: (doc: LibraryDocument) => void;
74
+ /** App-specific controls, rendered in the identity panel's footer. */
75
+ actions?: Snippet;
76
+ /** App-specific sections rendered after the documents. */
77
+ children?: Snippet;
78
+ } = $props();
79
+
80
+ const shownTotal = $derived(documentsTotal ?? documents.length);
81
+ // Floored at 1 so a consumer's limit: 0 cannot put Infinity in the pager.
82
+ const pageSize = $derived(Math.max(1, limit));
83
+ const pageCount = $derived(Math.max(1, Math.ceil(shownTotal / pageSize)));
84
+ const page = $derived(Math.floor(offset / pageSize) + 1);
85
+ </script>
86
+
87
+ <div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
88
+ {#if loading}
89
+ <LoadingState message="Loading the collection…" />
90
+ {:else if error}
91
+ <ErrorState message={error}>
92
+ {#snippet action()}
93
+ {#if onRetry}
94
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
95
+ {/if}
96
+ {/snippet}
97
+ </ErrorState>
98
+ {:else if !collection}
99
+ <EmptyState title={notFoundTitle} description={notFoundDescription} />
100
+ {:else}
101
+ <DetailPanel
102
+ eyebrow="Collection"
103
+ title={collection.name}
104
+ titleFace="display"
105
+ status={collection.badge?.status}
106
+ statusLabel={collection.badge?.label}
107
+ footer={actions}
108
+ >
109
+ {#if collection.subtitle}
110
+ <p class="text-muted-foreground text-sm">{collection.subtitle}</p>
111
+ {/if}
112
+ {#if collection.description}
113
+ <p class="mt-2 max-w-prose text-sm">{collection.description}</p>
114
+ {/if}
115
+ {#if !collection.subtitle && !collection.description}
116
+ <p class="text-muted-foreground text-sm">No description recorded.</p>
117
+ {/if}
118
+ </DetailPanel>
119
+
120
+ {#if stats.length > 0}
121
+ <StatList items={stats} />
122
+ {/if}
123
+
124
+ {#if documentsLoading}
125
+ <LoadingState message="Loading the documents…" />
126
+ {:else if documentsError}
127
+ <ErrorState message={documentsError}>
128
+ {#snippet action()}
129
+ {#if onRetry}
130
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
131
+ {/if}
132
+ {/snippet}
133
+ </ErrorState>
134
+ {:else if documents.length === 0}
135
+ <EmptyState title={emptyTitle} description={emptyDescription} />
136
+ {:else}
137
+ <Panel title="Documents" subtitle="{shownTotal} document{shownTotal === 1 ? '' : 's'}">
138
+ <div class="flex flex-col gap-3">
139
+ <DocumentTable {documents} {documentHref} onOpen={onOpenDocument} />
140
+ {#if onPageChange && shownTotal > pageSize}
141
+ <div class="flex flex-wrap items-center justify-end gap-2">
142
+ <Button
143
+ variant="outline"
144
+ size="sm"
145
+ disabled={offset === 0}
146
+ onclick={() => onPageChange(Math.max(0, offset - pageSize))}
147
+ >
148
+ Previous
149
+ </Button>
150
+ <span class="text-muted-foreground text-xs">Page {page} of {pageCount}</span>
151
+ <Button
152
+ variant="outline"
153
+ size="sm"
154
+ disabled={offset + pageSize >= shownTotal}
155
+ onclick={() => onPageChange(offset + pageSize)}
156
+ >
157
+ Next
158
+ </Button>
159
+ </div>
160
+ {/if}
161
+ </div>
162
+ </Panel>
163
+ {/if}
164
+
165
+ {#if children}
166
+ {@render children()}
167
+ {/if}
168
+ {/if}
169
+ </div>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CollectionDetail — one collection's surface: its identity (name, meta,
3
+ * state), an at-a-glance stat list, and the documents it holds.
4
+ *
5
+ * Fixed props over plain data (#30): the page fetches, maps its responses
6
+ * into the shared library vocabulary, and owns the pager offset. Anything
7
+ * app-specific — a config form, an operations tab, action buttons — comes
8
+ * in through `actions` (the identity panel's footer) and `children`
9
+ * (sections rendered after the documents), so the app extends the surface
10
+ * without forking it.
11
+ */
12
+ import type { Snippet } from 'svelte';
13
+ import type { HTMLAttributes } from 'svelte/elements';
14
+ import { type WithElementRef } from '../../../utils.js';
15
+ import type { StatItem } from '../stat-list/stat-list.svelte';
16
+ import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
17
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
18
+ collection: LibraryCollection | null;
19
+ /** At-a-glance figures for the collection (document count, indexed, …). */
20
+ stats?: StatItem[];
21
+ documents?: LibraryDocument[];
22
+ /** Total documents across all pages; defaults to the rows given. */
23
+ documentsTotal?: number;
24
+ offset?: number;
25
+ limit?: number;
26
+ /** The collection itself is still loading. */
27
+ loading?: boolean;
28
+ error?: string | null;
29
+ /** The document list loads independently of the identity. */
30
+ documentsLoading?: boolean;
31
+ documentsError?: string | null;
32
+ emptyTitle?: string;
33
+ emptyDescription?: string;
34
+ notFoundTitle?: string;
35
+ notFoundDescription?: string;
36
+ onPageChange?: (offset: number) => void;
37
+ onRetry?: () => void;
38
+ documentHref?: (doc: LibraryDocument) => string;
39
+ onOpenDocument?: (doc: LibraryDocument) => void;
40
+ /** App-specific controls, rendered in the identity panel's footer. */
41
+ actions?: Snippet;
42
+ /** App-specific sections rendered after the documents. */
43
+ children?: Snippet;
44
+ };
45
+ declare const CollectionDetail: import("svelte").Component<$$ComponentProps, {}, "ref">;
46
+ type CollectionDetail = ReturnType<typeof CollectionDetail>;
47
+ export default CollectionDetail;
@@ -0,0 +1,3 @@
1
+ export { default as CollectionDetail } from './collection-detail.svelte';
2
+ export { default } from './collection-detail.svelte';
3
+ export type { LibraryBadge, LibraryCollection, LibraryDocument } from '../library-browse/types.js';
@@ -0,0 +1,2 @@
1
+ export { default as CollectionDetail } from './collection-detail.svelte';
2
+ export { default } from './collection-detail.svelte';
@@ -0,0 +1,184 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DocumentDetail — one document's surface: its identity fields, recorded
4
+ * locations, tags, and collection memberships.
5
+ *
6
+ * Fixed props over plain data (#30). Sections render only when their data
7
+ * is present, so a consumer whose documents carry no locations never shows
8
+ * an empty heading. App-specific work — retract/re-extract buttons, an
9
+ * operations history — comes in through `actions` (the panel's footer) and
10
+ * `children` (sections after the panel); membership links are the app's
11
+ * own routed links via `collectionHref`, because this package has no
12
+ * router.
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ import type { HTMLAttributes } from 'svelte/elements';
16
+ import { cn, type WithElementRef } from '../../../utils.js';
17
+ import Button from '../button/button.svelte';
18
+ import { Badge } from '../badge/index.js';
19
+ import DetailPanel from '../detail-panel/detail-panel.svelte';
20
+ import StatusBadge from '../status-badge/status-badge.svelte';
21
+ import EmptyState from '../empty-state/empty-state.svelte';
22
+ import ErrorState from '../error-state/error-state.svelte';
23
+ import LoadingState from '../loading-state/loading-state.svelte';
24
+ import type { LibraryDocumentDetail, LibraryMembership } from '../library-browse/types.js';
25
+
26
+ let {
27
+ document,
28
+ loading = false,
29
+ error = null,
30
+ notFoundTitle = 'Document not found',
31
+ notFoundDescription = 'This document is not in the catalogue.',
32
+ collectionHref,
33
+ onOpenCollection,
34
+ onRetry,
35
+ actions,
36
+ children,
37
+ ref = $bindable(null),
38
+ class: className,
39
+ ...restProps
40
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
41
+ document: LibraryDocumentDetail | null;
42
+ loading?: boolean;
43
+ error?: string | null;
44
+ notFoundTitle?: string;
45
+ notFoundDescription?: string;
46
+ /** The app's own routed link per membership. */
47
+ collectionHref?: (membership: LibraryMembership) => string;
48
+ onOpenCollection?: (membership: LibraryMembership) => void;
49
+ onRetry?: () => void;
50
+ /** App-specific controls, rendered in the panel's footer. */
51
+ actions?: Snippet;
52
+ /** App-specific sections rendered after the panel. */
53
+ children?: Snippet;
54
+ } = $props();
55
+ </script>
56
+
57
+ <div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
58
+ {#if loading}
59
+ <LoadingState message="Loading the document…" />
60
+ {:else if error}
61
+ <ErrorState message={error}>
62
+ {#snippet action()}
63
+ {#if onRetry}
64
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
65
+ {/if}
66
+ {/snippet}
67
+ </ErrorState>
68
+ {:else if !document}
69
+ <EmptyState title={notFoundTitle} description={notFoundDescription} />
70
+ {:else}
71
+ <DetailPanel eyebrow="Document" title={document.title} titleFace="display" footer={actions}>
72
+ <div class="grid gap-4 sm:grid-cols-2">
73
+ {#if document.fields?.length}
74
+ <div>
75
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
76
+ Details
77
+ </h3>
78
+ <dl class="space-y-1 text-sm">
79
+ {#each document.fields as field (field.label)}
80
+ <div class="flex justify-between gap-4">
81
+ <dt class="text-muted-foreground">{field.label}</dt>
82
+ <dd class={field.mono ? 'truncate font-mono text-xs' : 'text-right'}>
83
+ {field.value}
84
+ </dd>
85
+ </div>
86
+ {/each}
87
+ </dl>
88
+ </div>
89
+ {/if}
90
+
91
+ {#if document.locations?.length}
92
+ <div>
93
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
94
+ Locations
95
+ </h3>
96
+ <ul class="space-y-1 text-sm">
97
+ {#each document.locations as location (location.path)}
98
+ <li class="flex items-center justify-between gap-2">
99
+ <span class="truncate font-mono text-xs">{location.path}</span>
100
+ <div class="flex shrink-0 items-center gap-1">
101
+ {#if location.primary}
102
+ <Badge variant="secondary">primary</Badge>
103
+ {/if}
104
+ {#if location.badge}
105
+ <StatusBadge status={location.badge.status} label={location.badge.label} />
106
+ {/if}
107
+ </div>
108
+ </li>
109
+ {/each}
110
+ </ul>
111
+ </div>
112
+ {/if}
113
+
114
+ {#if document.tags?.length}
115
+ <div>
116
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
117
+ Tags
118
+ </h3>
119
+ <div class="flex flex-wrap gap-1.5">
120
+ {#each document.tags as tag (tag)}
121
+ <Badge variant="outline">{tag}</Badge>
122
+ {/each}
123
+ </div>
124
+ </div>
125
+ {/if}
126
+
127
+ {#if document.memberships?.length}
128
+ <div>
129
+ <h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
130
+ Collections
131
+ </h3>
132
+ <div class="flex flex-wrap gap-1.5">
133
+ {#each document.memberships as membership (membership.id)}
134
+ {#if collectionHref}
135
+ <a
136
+ href={collectionHref(membership)}
137
+ class="inline-flex items-center gap-1"
138
+ onclick={() => onOpenCollection?.(membership)}
139
+ >
140
+ <Badge variant="outline">{membership.name}</Badge>
141
+ {#if membership.badge}
142
+ <StatusBadge
143
+ status={membership.badge.status}
144
+ label={membership.badge.label}
145
+ />
146
+ {/if}
147
+ </a>
148
+ {:else if onOpenCollection}
149
+ <button
150
+ type="button"
151
+ class="inline-flex items-center gap-1"
152
+ onclick={() => onOpenCollection(membership)}
153
+ >
154
+ <Badge variant="outline">{membership.name}</Badge>
155
+ {#if membership.badge}
156
+ <StatusBadge
157
+ status={membership.badge.status}
158
+ label={membership.badge.label}
159
+ />
160
+ {/if}
161
+ </button>
162
+ {:else}
163
+ <span class="inline-flex items-center gap-1">
164
+ <Badge variant="outline">{membership.name}</Badge>
165
+ {#if membership.badge}
166
+ <StatusBadge
167
+ status={membership.badge.status}
168
+ label={membership.badge.label}
169
+ />
170
+ {/if}
171
+ </span>
172
+ {/if}
173
+ {/each}
174
+ </div>
175
+ </div>
176
+ {/if}
177
+ </div>
178
+ </DetailPanel>
179
+
180
+ {#if children}
181
+ {@render children()}
182
+ {/if}
183
+ {/if}
184
+ </div>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * DocumentDetail — one document's surface: its identity fields, recorded
3
+ * locations, tags, and collection memberships.
4
+ *
5
+ * Fixed props over plain data (#30). Sections render only when their data
6
+ * is present, so a consumer whose documents carry no locations never shows
7
+ * an empty heading. App-specific work — retract/re-extract buttons, an
8
+ * operations history — comes in through `actions` (the panel's footer) and
9
+ * `children` (sections after the panel); membership links are the app's
10
+ * own routed links via `collectionHref`, because this package has no
11
+ * router.
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+ import type { HTMLAttributes } from 'svelte/elements';
15
+ import { type WithElementRef } from '../../../utils.js';
16
+ import type { LibraryDocumentDetail, LibraryMembership } from '../library-browse/types.js';
17
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
18
+ document: LibraryDocumentDetail | null;
19
+ loading?: boolean;
20
+ error?: string | null;
21
+ notFoundTitle?: string;
22
+ notFoundDescription?: string;
23
+ /** The app's own routed link per membership. */
24
+ collectionHref?: (membership: LibraryMembership) => string;
25
+ onOpenCollection?: (membership: LibraryMembership) => void;
26
+ onRetry?: () => void;
27
+ /** App-specific controls, rendered in the panel's footer. */
28
+ actions?: Snippet;
29
+ /** App-specific sections rendered after the panel. */
30
+ children?: Snippet;
31
+ };
32
+ declare const DocumentDetail: import("svelte").Component<$$ComponentProps, {}, "ref">;
33
+ type DocumentDetail = ReturnType<typeof DocumentDetail>;
34
+ export default DocumentDetail;
@@ -0,0 +1,3 @@
1
+ export { default as DocumentDetail } from './document-detail.svelte';
2
+ export { default } from './document-detail.svelte';
3
+ export type { LibraryBadge, LibraryDocumentDetail, LibraryField, LibraryLocation, LibraryMembership } from '../library-browse/types.js';
@@ -0,0 +1,2 @@
1
+ export { default as DocumentDetail } from './document-detail.svelte';
2
+ export { default } from './document-detail.svelte';
@@ -0,0 +1,101 @@
1
+ <script lang="ts">
2
+ // The one catalogue table, shared by LibraryBrowse and CollectionDetail so
3
+ // the two surfaces cannot drift apart. Columns come and go with the data —
4
+ // a consumer that passes no `collections` never sees an empty column —
5
+ // which is what lets one table serve both the whole-catalogue view and a
6
+ // single collection's slice.
7
+ import {
8
+ Table as TableRoot,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow
14
+ } from '../table/index.js';
15
+ import StatusBadge from '../status-badge/status-badge.svelte';
16
+ import type { LibraryDocument } from './types.js';
17
+
18
+ let {
19
+ documents,
20
+ documentHref,
21
+ onOpen
22
+ }: {
23
+ documents: LibraryDocument[];
24
+ /** The app's own routed link per row; this package has no router of its own. */
25
+ documentHref?: (doc: LibraryDocument) => string;
26
+ onOpen?: (doc: LibraryDocument) => void;
27
+ } = $props();
28
+
29
+ const hasTags = $derived(documents.some((d) => d.tags?.length));
30
+ const hasCollections = $derived(documents.some((d) => d.collections?.length));
31
+ const hasBadges = $derived(documents.some((d) => d.badges?.length));
32
+ </script>
33
+
34
+ <div class="rounded-md border">
35
+ <TableRoot>
36
+ <TableHeader>
37
+ <TableRow>
38
+ <TableHead>Title</TableHead>
39
+ {#if hasTags}
40
+ <TableHead class="w-40">Tags</TableHead>
41
+ {/if}
42
+ {#if hasCollections}
43
+ <TableHead class="w-40">Collections</TableHead>
44
+ {/if}
45
+ {#if hasBadges}
46
+ <TableHead class="w-40 text-center">Status</TableHead>
47
+ {/if}
48
+ </TableRow>
49
+ </TableHeader>
50
+ <TableBody>
51
+ {#each documents as doc (doc.id)}
52
+ <TableRow>
53
+ <TableCell>
54
+ {#if documentHref}
55
+ <a
56
+ href={documentHref(doc)}
57
+ class="font-medium hover:underline"
58
+ onclick={() => onOpen?.(doc)}
59
+ >
60
+ {doc.title}
61
+ </a>
62
+ {:else if onOpen}
63
+ <button
64
+ type="button"
65
+ class="text-left font-medium hover:underline"
66
+ onclick={() => onOpen(doc)}
67
+ >
68
+ {doc.title}
69
+ </button>
70
+ {:else}
71
+ <span class="font-medium">{doc.title}</span>
72
+ {/if}
73
+ </TableCell>
74
+ {#if hasTags}
75
+ <TableCell class="text-muted-foreground text-xs">
76
+ {doc.tags?.join(', ') || '—'}
77
+ </TableCell>
78
+ {/if}
79
+ {#if hasCollections}
80
+ <TableCell class="text-muted-foreground text-xs">
81
+ {doc.collections?.join(', ') || '—'}
82
+ </TableCell>
83
+ {/if}
84
+ {#if hasBadges}
85
+ <TableCell class="text-center">
86
+ <div class="flex flex-wrap justify-center gap-1">
87
+ <!-- Keyed by index: LibraryBadge carries no id, and two memberships
88
+ can legitimately map to the same label+status. -->
89
+ {#each doc.badges ?? [] as badge, i (i)}
90
+ <StatusBadge status={badge.status} label={badge.label} />
91
+ {:else}
92
+ <span class="text-muted-foreground text-xs">—</span>
93
+ {/each}
94
+ </div>
95
+ </TableCell>
96
+ {/if}
97
+ </TableRow>
98
+ {/each}
99
+ </TableBody>
100
+ </TableRoot>
101
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { LibraryDocument } from './types.js';
2
+ type $$ComponentProps = {
3
+ documents: LibraryDocument[];
4
+ /** The app's own routed link per row; this package has no router of its own. */
5
+ documentHref?: (doc: LibraryDocument) => string;
6
+ onOpen?: (doc: LibraryDocument) => void;
7
+ };
8
+ declare const DocumentTable: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type DocumentTable = ReturnType<typeof DocumentTable>;
10
+ export default DocumentTable;
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ // The facet rail beside the catalogue. Selection state belongs to the page
3
+ // (the app re-queries its own backend on change); this only renders the
4
+ // dimensions it is given and hands the next selection back.
5
+ import type { LibraryFacet } from './types.js';
6
+
7
+ let {
8
+ facets,
9
+ onChange
10
+ }: {
11
+ facets: LibraryFacet[];
12
+ onChange?: (key: string, selected: string[]) => void;
13
+ } = $props();
14
+
15
+ function toggle(facet: LibraryFacet, value: string) {
16
+ const has = facet.selected.includes(value);
17
+ if (facet.multiple) {
18
+ onChange?.(
19
+ facet.key,
20
+ has ? facet.selected.filter((v) => v !== value) : [...facet.selected, value]
21
+ );
22
+ } else {
23
+ onChange?.(facet.key, has ? [] : [value]);
24
+ }
25
+ }
26
+ </script>
27
+
28
+ <div class="space-y-4">
29
+ {#each facets as facet (facet.key)}
30
+ <div>
31
+ <h3 class="text-muted-foreground text-2xs mb-1 font-semibold tracking-wide uppercase">
32
+ {facet.label}
33
+ </h3>
34
+ {#if facet.options.length === 0}
35
+ <p class="text-muted-foreground text-xs">Populates once the catalogue holds documents.</p>
36
+ {:else}
37
+ <ul class="space-y-0.5">
38
+ {#each facet.options as option (option.value)}
39
+ {@const active = facet.selected.includes(option.value)}
40
+ <li>
41
+ <button
42
+ type="button"
43
+ aria-pressed={active}
44
+ class="hover:bg-muted flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-sm {active
45
+ ? 'bg-muted font-medium'
46
+ : ''}"
47
+ onclick={() => toggle(facet, option.value)}
48
+ >
49
+ <span class="truncate">{option.label ?? option.value}</span>
50
+ {#if option.count !== undefined}
51
+ <span class="text-muted-foreground font-mono text-xs tabular-nums">
52
+ {option.count}
53
+ </span>
54
+ {/if}
55
+ </button>
56
+ </li>
57
+ {/each}
58
+ </ul>
59
+ {/if}
60
+ </div>
61
+ {/each}
62
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { LibraryFacet } from './types.js';
2
+ type $$ComponentProps = {
3
+ facets: LibraryFacet[];
4
+ onChange?: (key: string, selected: string[]) => void;
5
+ };
6
+ declare const FacetRail: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type FacetRail = ReturnType<typeof FacetRail>;
8
+ export default FacetRail;
@@ -0,0 +1,3 @@
1
+ export { default as LibraryBrowse } from './library-browse.svelte';
2
+ export { default } from './library-browse.svelte';
3
+ export type { LibraryBadge, LibraryDocument, LibraryFacet, LibraryFacetOption, LibraryField, LibraryLocation, LibraryMembership, LibraryDocumentDetail, LibraryCollection, LibrarySearchResult, SearchSnippetSegment } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as LibraryBrowse } from './library-browse.svelte';
2
+ export { default } from './library-browse.svelte';