@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.
- package/README.md +180 -30
- package/dist/components/ui/app-shell/app-nav.svelte +12 -12
- package/dist/components/ui/app-shell/app-shell.svelte +14 -12
- package/dist/components/ui/app-shell/app-shell.svelte.d.ts +14 -6
- package/dist/components/ui/app-shell/types.d.ts +20 -3
- package/dist/components/ui/app-shell/types.js +29 -5
- package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
- package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
- package/dist/components/ui/collection-detail/index.d.ts +3 -0
- package/dist/components/ui/collection-detail/index.js +2 -0
- package/dist/components/ui/document-detail/document-detail.svelte +184 -0
- package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
- package/dist/components/ui/document-detail/index.d.ts +3 -0
- package/dist/components/ui/document-detail/index.js +2 -0
- package/dist/components/ui/library-browse/document-table.svelte +101 -0
- package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
- package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
- package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
- package/dist/components/ui/library-browse/index.d.ts +3 -0
- package/dist/components/ui/library-browse/index.js +2 -0
- package/dist/components/ui/library-browse/library-browse.svelte +235 -0
- package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
- package/dist/components/ui/library-browse/types.d.ts +101 -0
- package/dist/components/ui/library-browse/types.js +1 -0
- package/dist/components/ui/schema-form/context.d.ts +31 -0
- package/dist/components/ui/schema-form/context.js +10 -0
- package/dist/components/ui/schema-form/data.d.ts +42 -0
- package/dist/components/ui/schema-form/data.js +97 -0
- package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
- package/dist/components/ui/schema-form/dispatch.js +139 -0
- package/dist/components/ui/schema-form/index.d.ts +5 -0
- package/dist/components/ui/schema-form/index.js +5 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
- package/dist/components/ui/schema-form/schema-form.svelte +153 -0
- package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
- package/dist/components/ui/schema-form/types.d.ts +61 -0
- package/dist/components/ui/schema-form/types.js +21 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
- package/dist/components/ui/search-results/index.d.ts +3 -0
- package/dist/components/ui/search-results/index.js +2 -0
- package/dist/components/ui/search-results/search-results.svelte +183 -0
- package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
- package/package.json +2 -1
- package/registry/component-map.json +52 -3
- package/registry/component-map.md +20 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LibraryBrowse — the faceted catalogue index: search, facet rail, active
|
|
4
|
+
* filter chips, the document table, and a pager.
|
|
5
|
+
*
|
|
6
|
+
* Fixed props over plain data, deliberately — library data is stable in
|
|
7
|
+
* shape, so it gets a fixed component; configuration is the schema-driven
|
|
8
|
+
* half and lives in <SchemaForm> (#30). The component fetches nothing and
|
|
9
|
+
* routes nothing: the page owns the query, the facet selections and the
|
|
10
|
+
* offset, re-queries its own backend on every callback, and supplies its
|
|
11
|
+
* own routed link per row via `documentHref`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
15
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
16
|
+
import Search from '@lucide/svelte/icons/search';
|
|
17
|
+
import LibraryIcon from '@lucide/svelte/icons/library';
|
|
18
|
+
import X from '@lucide/svelte/icons/x';
|
|
19
|
+
import { Input } from '../input/index.js';
|
|
20
|
+
import Button from '../button/button.svelte';
|
|
21
|
+
import { Badge } from '../badge/index.js';
|
|
22
|
+
import EmptyState from '../empty-state/empty-state.svelte';
|
|
23
|
+
import ErrorState from '../error-state/error-state.svelte';
|
|
24
|
+
import LoadingState from '../loading-state/loading-state.svelte';
|
|
25
|
+
import DocumentTable from './document-table.svelte';
|
|
26
|
+
import FacetRail from './facet-rail.svelte';
|
|
27
|
+
import type { LibraryDocument, LibraryFacet } from './types.js';
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
documents,
|
|
31
|
+
facets = [],
|
|
32
|
+
query = '',
|
|
33
|
+
total,
|
|
34
|
+
offset = 0,
|
|
35
|
+
limit = 25,
|
|
36
|
+
loading = false,
|
|
37
|
+
error = null,
|
|
38
|
+
searchPlaceholder = 'Search the catalogue…',
|
|
39
|
+
emptyTitle = 'The catalogue is empty',
|
|
40
|
+
emptyDescription,
|
|
41
|
+
onQueryChange,
|
|
42
|
+
onFacetChange,
|
|
43
|
+
onPageChange,
|
|
44
|
+
onRetry,
|
|
45
|
+
documentHref,
|
|
46
|
+
onOpenDocument,
|
|
47
|
+
toolbar,
|
|
48
|
+
ref = $bindable(null),
|
|
49
|
+
class: className,
|
|
50
|
+
...restProps
|
|
51
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
52
|
+
documents: LibraryDocument[];
|
|
53
|
+
facets?: LibraryFacet[];
|
|
54
|
+
/** The current search term. The page owns it; typing fires `onQueryChange`. */
|
|
55
|
+
query?: string;
|
|
56
|
+
/** Total matches across all pages; defaults to the rows given. */
|
|
57
|
+
total?: number;
|
|
58
|
+
offset?: number;
|
|
59
|
+
limit?: number;
|
|
60
|
+
loading?: boolean;
|
|
61
|
+
error?: string | null;
|
|
62
|
+
searchPlaceholder?: string;
|
|
63
|
+
/** Copy for a catalogue that is genuinely empty (no filter active). */
|
|
64
|
+
emptyTitle?: string;
|
|
65
|
+
emptyDescription?: string;
|
|
66
|
+
onQueryChange?: (query: string) => void;
|
|
67
|
+
onFacetChange?: (key: string, selected: string[]) => void;
|
|
68
|
+
/** Called with the next offset when the pager is used. */
|
|
69
|
+
onPageChange?: (offset: number) => void;
|
|
70
|
+
onRetry?: () => void;
|
|
71
|
+
/** The app's own routed link per row; this package has no router. */
|
|
72
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
73
|
+
onOpenDocument?: (doc: LibraryDocument) => void;
|
|
74
|
+
/** Trailing controls beside the search field (e.g. a saved-search menu). */
|
|
75
|
+
toolbar?: Snippet;
|
|
76
|
+
} = $props();
|
|
77
|
+
|
|
78
|
+
interface Chip {
|
|
79
|
+
key: string;
|
|
80
|
+
label: string;
|
|
81
|
+
remove: () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Chips are derived, never state: the page owns query and selections, so a
|
|
85
|
+
// chip's removal is just the matching callback with the value taken out.
|
|
86
|
+
const chips = $derived.by((): Chip[] => {
|
|
87
|
+
const out: Chip[] = [];
|
|
88
|
+
if (query) out.push({ key: 'q', label: `“${query}”`, remove: () => onQueryChange?.('') });
|
|
89
|
+
for (const facet of facets) {
|
|
90
|
+
for (const value of facet.selected) {
|
|
91
|
+
out.push({
|
|
92
|
+
key: `${facet.key}:${value}`,
|
|
93
|
+
label: `${facet.label.toLowerCase()}: ${value}`,
|
|
94
|
+
remove: () =>
|
|
95
|
+
onFacetChange?.(
|
|
96
|
+
facet.key,
|
|
97
|
+
facet.selected.filter((v) => v !== value)
|
|
98
|
+
)
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
});
|
|
104
|
+
const filtered = $derived(chips.length > 0);
|
|
105
|
+
|
|
106
|
+
function clearAll() {
|
|
107
|
+
if (query) onQueryChange?.('');
|
|
108
|
+
for (const facet of facets) {
|
|
109
|
+
if (facet.selected.length) onFacetChange?.(facet.key, []);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const shownTotal = $derived(total ?? documents.length);
|
|
114
|
+
// Floored at 1 so a consumer's limit: 0 cannot put Infinity in the pager.
|
|
115
|
+
const pageSize = $derived(Math.max(1, limit));
|
|
116
|
+
const pageCount = $derived(Math.max(1, Math.ceil(shownTotal / pageSize)));
|
|
117
|
+
const page = $derived(Math.floor(offset / pageSize) + 1);
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
|
|
121
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
122
|
+
<form
|
|
123
|
+
class="relative w-full flex-none sm:w-80"
|
|
124
|
+
onsubmit={(e) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
onQueryChange?.(query);
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<Search
|
|
130
|
+
class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
|
|
131
|
+
/>
|
|
132
|
+
<Input
|
|
133
|
+
type="search"
|
|
134
|
+
value={query}
|
|
135
|
+
placeholder={searchPlaceholder}
|
|
136
|
+
class="pl-8"
|
|
137
|
+
oninput={(e) => onQueryChange?.((e.target as HTMLInputElement).value)}
|
|
138
|
+
/>
|
|
139
|
+
</form>
|
|
140
|
+
{#if toolbar}
|
|
141
|
+
<div class="flex items-center gap-2">{@render toolbar()}</div>
|
|
142
|
+
{/if}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{#if chips.length > 0}
|
|
146
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
147
|
+
{#each chips as chip (chip.key)}
|
|
148
|
+
<Badge variant="secondary" class="gap-1">
|
|
149
|
+
{chip.label}
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
aria-label="Remove filter {chip.label}"
|
|
153
|
+
class="hover:opacity-70"
|
|
154
|
+
onclick={chip.remove}
|
|
155
|
+
>
|
|
156
|
+
<X class="size-3" />
|
|
157
|
+
</button>
|
|
158
|
+
</Badge>
|
|
159
|
+
{/each}
|
|
160
|
+
<Button variant="ghost" size="sm" onclick={clearAll}>Clear all</Button>
|
|
161
|
+
</div>
|
|
162
|
+
{/if}
|
|
163
|
+
|
|
164
|
+
<div
|
|
165
|
+
class={facets.length > 0 ? 'grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]' : 'grid gap-6'}
|
|
166
|
+
>
|
|
167
|
+
{#if facets.length > 0}
|
|
168
|
+
<aside class="hidden md:block" aria-label="Filters">
|
|
169
|
+
<FacetRail {facets} onChange={onFacetChange} />
|
|
170
|
+
</aside>
|
|
171
|
+
{/if}
|
|
172
|
+
|
|
173
|
+
<!-- min-w-0: a grid item defaults to min-width:auto, so without it this
|
|
174
|
+
column stretches to the table's intrinsic width and the table's own
|
|
175
|
+
scroller never gets a bounded box — the excess lands on the shell's
|
|
176
|
+
scroller instead (measured in the first consumer). minmax(0,1fr) on
|
|
177
|
+
the track carries the same guarantee at the grid level. -->
|
|
178
|
+
<div class="flex min-w-0 flex-col gap-3">
|
|
179
|
+
{#if loading}
|
|
180
|
+
<LoadingState message="Loading the catalogue…" />
|
|
181
|
+
{:else if error}
|
|
182
|
+
<ErrorState message={error}>
|
|
183
|
+
{#snippet action()}
|
|
184
|
+
{#if onRetry}
|
|
185
|
+
<Button variant="outline" onclick={onRetry}>Retry</Button>
|
|
186
|
+
{/if}
|
|
187
|
+
{/snippet}
|
|
188
|
+
</ErrorState>
|
|
189
|
+
{:else if documents.length === 0}
|
|
190
|
+
{#if filtered}
|
|
191
|
+
<EmptyState
|
|
192
|
+
icon={LibraryIcon}
|
|
193
|
+
title="No documents match this search"
|
|
194
|
+
description="Try a different term, or clear the filters to see the whole catalogue."
|
|
195
|
+
>
|
|
196
|
+
{#snippet action()}
|
|
197
|
+
<Button variant="outline" onclick={clearAll}>Clear filters</Button>
|
|
198
|
+
{/snippet}
|
|
199
|
+
</EmptyState>
|
|
200
|
+
{:else}
|
|
201
|
+
<EmptyState icon={LibraryIcon} title={emptyTitle} description={emptyDescription} />
|
|
202
|
+
{/if}
|
|
203
|
+
{:else}
|
|
204
|
+
<DocumentTable {documents} {documentHref} onOpen={onOpenDocument} />
|
|
205
|
+
|
|
206
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
207
|
+
<p class="text-muted-foreground text-xs">
|
|
208
|
+
{shownTotal} document{shownTotal === 1 ? '' : 's'}
|
|
209
|
+
</p>
|
|
210
|
+
{#if onPageChange && shownTotal > pageSize}
|
|
211
|
+
<div class="flex items-center gap-2">
|
|
212
|
+
<Button
|
|
213
|
+
variant="outline"
|
|
214
|
+
size="sm"
|
|
215
|
+
disabled={offset === 0}
|
|
216
|
+
onclick={() => onPageChange(Math.max(0, offset - pageSize))}
|
|
217
|
+
>
|
|
218
|
+
Previous
|
|
219
|
+
</Button>
|
|
220
|
+
<span class="text-muted-foreground text-xs">Page {page} of {pageCount}</span>
|
|
221
|
+
<Button
|
|
222
|
+
variant="outline"
|
|
223
|
+
size="sm"
|
|
224
|
+
disabled={offset + pageSize >= shownTotal}
|
|
225
|
+
onclick={() => onPageChange(offset + pageSize)}
|
|
226
|
+
>
|
|
227
|
+
Next
|
|
228
|
+
</Button>
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LibraryBrowse — the faceted catalogue index: search, facet rail, active
|
|
3
|
+
* filter chips, the document table, and a pager.
|
|
4
|
+
*
|
|
5
|
+
* Fixed props over plain data, deliberately — library data is stable in
|
|
6
|
+
* shape, so it gets a fixed component; configuration is the schema-driven
|
|
7
|
+
* half and lives in <SchemaForm> (#30). The component fetches nothing and
|
|
8
|
+
* routes nothing: the page owns the query, the facet selections and the
|
|
9
|
+
* offset, re-queries its own backend on every callback, and supplies its
|
|
10
|
+
* own routed link per row via `documentHref`.
|
|
11
|
+
*/
|
|
12
|
+
import type { Snippet } from 'svelte';
|
|
13
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
14
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
15
|
+
import type { LibraryDocument, LibraryFacet } from './types.js';
|
|
16
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
17
|
+
documents: LibraryDocument[];
|
|
18
|
+
facets?: LibraryFacet[];
|
|
19
|
+
/** The current search term. The page owns it; typing fires `onQueryChange`. */
|
|
20
|
+
query?: string;
|
|
21
|
+
/** Total matches across all pages; defaults to the rows given. */
|
|
22
|
+
total?: number;
|
|
23
|
+
offset?: number;
|
|
24
|
+
limit?: number;
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
error?: string | null;
|
|
27
|
+
searchPlaceholder?: string;
|
|
28
|
+
/** Copy for a catalogue that is genuinely empty (no filter active). */
|
|
29
|
+
emptyTitle?: string;
|
|
30
|
+
emptyDescription?: string;
|
|
31
|
+
onQueryChange?: (query: string) => void;
|
|
32
|
+
onFacetChange?: (key: string, selected: string[]) => void;
|
|
33
|
+
/** Called with the next offset when the pager is used. */
|
|
34
|
+
onPageChange?: (offset: number) => void;
|
|
35
|
+
onRetry?: () => void;
|
|
36
|
+
/** The app's own routed link per row; this package has no router. */
|
|
37
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
38
|
+
onOpenDocument?: (doc: LibraryDocument) => void;
|
|
39
|
+
/** Trailing controls beside the search field (e.g. a saved-search menu). */
|
|
40
|
+
toolbar?: Snippet;
|
|
41
|
+
};
|
|
42
|
+
declare const LibraryBrowse: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
43
|
+
type LibraryBrowse = ReturnType<typeof LibraryBrowse>;
|
|
44
|
+
export default LibraryBrowse;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Status } from '../status/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The shared library-data vocabulary — plain shapes a consuming app maps its
|
|
4
|
+
* own API responses into. This package holds no HTTP client, no endpoint
|
|
5
|
+
* string, and no knowledge of any backend or app; that is what lets the
|
|
6
|
+
* backend be replaced without touching a consumer, and it is not negotiable
|
|
7
|
+
* for convenience (#30). An app's own state vocabulary (whatever its wire
|
|
8
|
+
* format says) is mapped by the app onto the shared five-state `Status`
|
|
9
|
+
* before it arrives here, as a `LibraryBadge`.
|
|
10
|
+
*/
|
|
11
|
+
/** A consumer-mapped state chip: one of the five shared states plus the app's label. */
|
|
12
|
+
export interface LibraryBadge {
|
|
13
|
+
status: Status;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
/** One catalogue row. */
|
|
17
|
+
export interface LibraryDocument {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
/** Free labels on the document. */
|
|
21
|
+
tags?: string[];
|
|
22
|
+
/** Names of the collections it belongs to. */
|
|
23
|
+
collections?: string[];
|
|
24
|
+
/** Consumer-mapped state chips (e.g. one per collection's index state). */
|
|
25
|
+
badges?: LibraryBadge[];
|
|
26
|
+
}
|
|
27
|
+
/** One choice inside a facet. */
|
|
28
|
+
export interface LibraryFacetOption {
|
|
29
|
+
value: string;
|
|
30
|
+
/** Documents carrying it; omit to render no count. */
|
|
31
|
+
count?: number;
|
|
32
|
+
/** Display label when the wire value is not presentable; defaults to `value`. */
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
/** One facet dimension in the browse rail. The page owns the selection state. */
|
|
36
|
+
export interface LibraryFacet {
|
|
37
|
+
/** Stable key handed back through `onFacetChange`. */
|
|
38
|
+
key: string;
|
|
39
|
+
label: string;
|
|
40
|
+
options: LibraryFacetOption[];
|
|
41
|
+
/** Currently selected wire values. */
|
|
42
|
+
selected: string[];
|
|
43
|
+
/** Allow several selections at once (default: a single toggle). */
|
|
44
|
+
multiple?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** A label→value row in a document's field list. */
|
|
47
|
+
export interface LibraryField {
|
|
48
|
+
label: string;
|
|
49
|
+
value: string;
|
|
50
|
+
/** Render the value in the code/data face (a hash, a path, a machine value). */
|
|
51
|
+
mono?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** One recorded location of a document. */
|
|
54
|
+
export interface LibraryLocation {
|
|
55
|
+
path: string;
|
|
56
|
+
primary?: boolean;
|
|
57
|
+
badge?: LibraryBadge;
|
|
58
|
+
}
|
|
59
|
+
/** One collection membership of a document. */
|
|
60
|
+
export interface LibraryMembership {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
badge?: LibraryBadge;
|
|
64
|
+
}
|
|
65
|
+
/** The full detail of one document. */
|
|
66
|
+
export interface LibraryDocumentDetail {
|
|
67
|
+
id: string;
|
|
68
|
+
title: string;
|
|
69
|
+
fields?: LibraryField[];
|
|
70
|
+
tags?: string[];
|
|
71
|
+
locations?: LibraryLocation[];
|
|
72
|
+
memberships?: LibraryMembership[];
|
|
73
|
+
}
|
|
74
|
+
/** The identity of one collection. */
|
|
75
|
+
export interface LibraryCollection {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
/** One already-formatted meta line (owner · pipeline · state). */
|
|
79
|
+
subtitle?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
badge?: LibraryBadge;
|
|
82
|
+
}
|
|
83
|
+
/** A snippet segment; `highlight` marks the part that matched the query. */
|
|
84
|
+
export interface SearchSnippetSegment {
|
|
85
|
+
text: string;
|
|
86
|
+
highlight?: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** One ranked search hit. */
|
|
89
|
+
export interface LibrarySearchResult {
|
|
90
|
+
id: string;
|
|
91
|
+
title: string;
|
|
92
|
+
/** The matched passage: plain text, or segments carrying highlights. */
|
|
93
|
+
snippet?: string | SearchSnippetSegment[];
|
|
94
|
+
/** Relevance in [0, 1]; rendered as a percentage. */
|
|
95
|
+
score?: number;
|
|
96
|
+
/** Where the hit came from — a collection or corpus name. */
|
|
97
|
+
source?: string;
|
|
98
|
+
badges?: LibraryBadge[];
|
|
99
|
+
/** One already-formatted trailing meta line (a date, a type). */
|
|
100
|
+
meta?: string;
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { createAjv, JsonSchema } from '@jsonforms/core';
|
|
2
|
+
/**
|
|
3
|
+
* The Ajv instance `@jsonforms/core` builds. Typed through its own factory so
|
|
4
|
+
* this package never names `ajv` — a transitive dependency of the engine, not
|
|
5
|
+
* a dependency of ours.
|
|
6
|
+
*/
|
|
7
|
+
export type FormAjv = ReturnType<typeof createAjv>;
|
|
8
|
+
/**
|
|
9
|
+
* The form-wide facts every nested element needs, published once by
|
|
10
|
+
* `<SchemaForm>` rather than drilled through a recursive component tree.
|
|
11
|
+
* Everything reactive is exposed as a getter, so a context read stays live
|
|
12
|
+
* under Svelte 5's signals.
|
|
13
|
+
*/
|
|
14
|
+
export interface SchemaFormContext {
|
|
15
|
+
/** The root JSON Schema every scope resolves against. */
|
|
16
|
+
readonly schema: JsonSchema;
|
|
17
|
+
/** The whole current value; rules and every widget read from here. */
|
|
18
|
+
readonly data: Record<string, unknown>;
|
|
19
|
+
/** Whether the whole form is disabled. */
|
|
20
|
+
readonly disabled: boolean;
|
|
21
|
+
/** Prefix for generated element ids. */
|
|
22
|
+
readonly idPrefix: string;
|
|
23
|
+
/** Validation messages by dot path, from the schema itself. */
|
|
24
|
+
readonly errors: Record<string, string[]>;
|
|
25
|
+
/** The shared Ajv instance rule conditions are evaluated with. */
|
|
26
|
+
readonly ajv: FormAjv;
|
|
27
|
+
/** Commit one field. */
|
|
28
|
+
change: (path: string, value: unknown) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare const setSchemaFormContext: (context: SchemaFormContext) => SchemaFormContext;
|
|
31
|
+
export declare const getSchemaFormContext: () => SchemaFormContext;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol('poodle64-schema-form');
|
|
3
|
+
export const setSchemaFormContext = (context) => setContext(KEY, context);
|
|
4
|
+
export const getSchemaFormContext = () => {
|
|
5
|
+
const context = getContext(KEY);
|
|
6
|
+
if (!context) {
|
|
7
|
+
throw new Error('@poodle64/ui: a schema-form part was rendered outside <SchemaForm>. Render the form, not its internals.');
|
|
8
|
+
}
|
|
9
|
+
return context;
|
|
10
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type JsonSchema, type UISchemaElement } from '@jsonforms/core';
|
|
2
|
+
import type { UnknownReason } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reading and writing a value at a dot path, and the walk that decides which
|
|
5
|
+
* schema properties the layout never mentioned.
|
|
6
|
+
*
|
|
7
|
+
* Dot paths are JSON Forms' own addressing (`toDataPath('#/properties/a/properties/b')`
|
|
8
|
+
* → `a.b`), so a property name containing a literal `.` is not addressable —
|
|
9
|
+
* the same limitation the wire format itself carries.
|
|
10
|
+
*/
|
|
11
|
+
export declare const scopeToPath: (scope: string) => string;
|
|
12
|
+
/** Read the value at a dot path, or `undefined`. */
|
|
13
|
+
export declare function getAt(data: unknown, path: string): unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Return a copy of `data` with `path` set to `value`. Every object along the
|
|
16
|
+
* way is copied, so a consumer holding the previous value still holds the
|
|
17
|
+
* previous value — a `$state` consumer and an immutable store both work.
|
|
18
|
+
*/
|
|
19
|
+
export declare function setAt(data: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
|
|
20
|
+
/** Every UI schema element in the tree, depth first. */
|
|
21
|
+
export declare function walkElements(element: UISchemaElement | undefined): UISchemaElement[];
|
|
22
|
+
/** The dot paths every Control in the tree addresses. */
|
|
23
|
+
export declare function addressedPaths(uischema: UISchemaElement | undefined): Set<string>;
|
|
24
|
+
export interface UnmappedField {
|
|
25
|
+
/** Dot path of the property nothing in the layout addresses. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** JSON Pointer scope, so the fix is a copy-paste into the UI schema. */
|
|
28
|
+
scope: string;
|
|
29
|
+
schema: JsonSchema;
|
|
30
|
+
reason: UnknownReason;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every schema property no Control addresses.
|
|
34
|
+
*
|
|
35
|
+
* This is the check that would have caught the estate's live defect: three
|
|
36
|
+
* whole top-level config groups never rendered because their names were absent
|
|
37
|
+
* from a hardcoded GROUP_ORDER, and a missing group produced no output and no
|
|
38
|
+
* error. Here a group nothing addresses is reported ONCE, at the group — the
|
|
39
|
+
* walk does not descend into a subtree the layout ignores entirely, so an
|
|
40
|
+
* unmentioned group is one loud entry rather than forty.
|
|
41
|
+
*/
|
|
42
|
+
export declare function unmappedFields(root: JsonSchema, addressed: Set<string>, node?: JsonSchema, scope?: string, path?: string): UnmappedField[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolveSchema, toDataPath } from '@jsonforms/core';
|
|
2
|
+
/**
|
|
3
|
+
* Reading and writing a value at a dot path, and the walk that decides which
|
|
4
|
+
* schema properties the layout never mentioned.
|
|
5
|
+
*
|
|
6
|
+
* Dot paths are JSON Forms' own addressing (`toDataPath('#/properties/a/properties/b')`
|
|
7
|
+
* → `a.b`), so a property name containing a literal `.` is not addressable —
|
|
8
|
+
* the same limitation the wire format itself carries.
|
|
9
|
+
*/
|
|
10
|
+
export const scopeToPath = (scope) => toDataPath(scope);
|
|
11
|
+
/** Read the value at a dot path, or `undefined`. */
|
|
12
|
+
export function getAt(data, path) {
|
|
13
|
+
if (!path)
|
|
14
|
+
return data;
|
|
15
|
+
let node = data;
|
|
16
|
+
for (const key of path.split('.')) {
|
|
17
|
+
if (node === null || typeof node !== 'object')
|
|
18
|
+
return undefined;
|
|
19
|
+
node = node[key];
|
|
20
|
+
}
|
|
21
|
+
return node;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Return a copy of `data` with `path` set to `value`. Every object along the
|
|
25
|
+
* way is copied, so a consumer holding the previous value still holds the
|
|
26
|
+
* previous value — a `$state` consumer and an immutable store both work.
|
|
27
|
+
*/
|
|
28
|
+
export function setAt(data, path, value) {
|
|
29
|
+
const keys = path.split('.');
|
|
30
|
+
const root = { ...data };
|
|
31
|
+
let node = root;
|
|
32
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
33
|
+
const key = keys[i];
|
|
34
|
+
const child = node[key];
|
|
35
|
+
node[key] = child && typeof child === 'object' && !Array.isArray(child)
|
|
36
|
+
? { ...child }
|
|
37
|
+
: {};
|
|
38
|
+
node = node[key];
|
|
39
|
+
}
|
|
40
|
+
node[keys[keys.length - 1]] = value;
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
/** Every UI schema element in the tree, depth first. */
|
|
44
|
+
export function walkElements(element) {
|
|
45
|
+
if (!element)
|
|
46
|
+
return [];
|
|
47
|
+
const children = element.elements ?? [];
|
|
48
|
+
return [element, ...children.flatMap(walkElements)];
|
|
49
|
+
}
|
|
50
|
+
/** The dot paths every Control in the tree addresses. */
|
|
51
|
+
export function addressedPaths(uischema) {
|
|
52
|
+
const paths = new Set();
|
|
53
|
+
for (const element of walkElements(uischema)) {
|
|
54
|
+
const scope = element.scope;
|
|
55
|
+
if (element.type === 'Control' && typeof scope === 'string' && scope.startsWith('#')) {
|
|
56
|
+
paths.add(scopeToPath(scope));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every schema property no Control addresses.
|
|
63
|
+
*
|
|
64
|
+
* This is the check that would have caught the estate's live defect: three
|
|
65
|
+
* whole top-level config groups never rendered because their names were absent
|
|
66
|
+
* from a hardcoded GROUP_ORDER, and a missing group produced no output and no
|
|
67
|
+
* error. Here a group nothing addresses is reported ONCE, at the group — the
|
|
68
|
+
* walk does not descend into a subtree the layout ignores entirely, so an
|
|
69
|
+
* unmentioned group is one loud entry rather than forty.
|
|
70
|
+
*/
|
|
71
|
+
export function unmappedFields(root, addressed, node = root, scope = '#', path = '') {
|
|
72
|
+
const properties = node.properties;
|
|
73
|
+
if (!properties)
|
|
74
|
+
return [];
|
|
75
|
+
const found = [];
|
|
76
|
+
for (const [key, raw] of Object.entries(properties)) {
|
|
77
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
78
|
+
const childScope = `${scope}/properties/${key}`;
|
|
79
|
+
// `resolveSchema` follows any $ref, so a property behind a definition is
|
|
80
|
+
// judged on the schema it actually resolves to.
|
|
81
|
+
const child = resolveSchema(root, childScope, root) ?? raw;
|
|
82
|
+
// Addressed BY ITSELF: a Control points at this property. Whatever it does
|
|
83
|
+
// with it is that Control's business — do not descend and second-guess it.
|
|
84
|
+
if (addressed.has(childPath))
|
|
85
|
+
continue;
|
|
86
|
+
// Addressed BELOW: some Control points inside it, so the layout knows about
|
|
87
|
+
// this subtree and only some of its leaves are missing.
|
|
88
|
+
if ([...addressed].some((addressedPath) => addressedPath.startsWith(`${childPath}.`))) {
|
|
89
|
+
found.push(...unmappedFields(root, addressed, child, childScope, childPath));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Addressed nowhere: report it here, once, and do not descend — an
|
|
93
|
+
// unmentioned group is one loud entry, not forty.
|
|
94
|
+
found.push({ path: childPath, scope: childScope, schema: child, reason: 'not-in-layout' });
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JsonSchema } from '@jsonforms/core';
|
|
2
|
+
import { type UnknownReason, type WidgetKind } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Control → widget resolution, as a pure function so the table can be asserted
|
|
5
|
+
* without rendering anything.
|
|
6
|
+
*
|
|
7
|
+
* THE RULE THIS FILE EXISTS FOR: every branch that cannot produce a real widget
|
|
8
|
+
* returns `{ widget: 'unknown', reason }`. There is no branch that returns
|
|
9
|
+
* nothing, and no branch that quietly substitutes a default for a hint it did
|
|
10
|
+
* not understand. The estate's previous home-grown renderer had both, and a
|
|
11
|
+
* `widget: "dropdown"` hint therefore rendered no input at all for months
|
|
12
|
+
* without anyone noticing.
|
|
13
|
+
*/
|
|
14
|
+
export interface WidgetChoice {
|
|
15
|
+
widget: WidgetKind;
|
|
16
|
+
/** Present exactly when `widget === 'unknown'`. */
|
|
17
|
+
reason?: UnknownReason;
|
|
18
|
+
/** Human-readable specifics for the flagged render, e.g. the rejected hint. */
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The first `type` a subschema declares (JSON Schema allows an array of them). */
|
|
22
|
+
export declare function schemaType(schema: JsonSchema | undefined): string | undefined;
|
|
23
|
+
/** Does this subschema carry a closed set of values a select can list? */
|
|
24
|
+
export declare function enumOptions(schema: JsonSchema | undefined): {
|
|
25
|
+
value: unknown;
|
|
26
|
+
label: string;
|
|
27
|
+
}[] | null;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a Control to a widget.
|
|
30
|
+
*
|
|
31
|
+
* @param schema the subschema the Control's scope resolved to, or `undefined`
|
|
32
|
+
* if the scope resolved to nothing at all.
|
|
33
|
+
* @param options the Control's `options` object from the UI schema.
|
|
34
|
+
*/
|
|
35
|
+
export declare function pickWidget(schema: JsonSchema | undefined, options?: Record<string, unknown>): WidgetChoice;
|