@poodle64/ui 2026.8.11 → 2026.8.13
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 +164 -20
- 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 +187 -0
- package/dist/components/ui/search-results/search-results.svelte.d.ts +44 -0
- package/package.json +2 -1
- package/registry/component-map.json +52 -3
- package/registry/component-map.md +20 -1
|
@@ -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,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,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
|
+
};
|