@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
package/README.md
CHANGED
|
@@ -74,30 +74,35 @@ the one-owner-per-key note below). It becomes a candidate the day two apps want
|
|
|
74
74
|
the same chart palette; until then a shared chart component would be a shared
|
|
75
75
|
disagreement.
|
|
76
76
|
|
|
77
|
-
**Composed components** (
|
|
77
|
+
**Composed components** (24). Primitives are not what makes an app look like an
|
|
78
78
|
app — the page chrome is. These are the cross-cutting surfaces every route
|
|
79
79
|
composes from, so a household app gets its layout language from the package
|
|
80
80
|
rather than rebuilding it:
|
|
81
81
|
|
|
82
|
-
| Import | What it is
|
|
83
|
-
| ----------------------------------------------- |
|
|
84
|
-
| `page-header` | The only page-title pattern: optional `breadcrumbs` snippet, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, an `actions` slot. Omit `title` for a header that is a breadcrumb bar.
|
|
85
|
-
| `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding.
|
|
86
|
-
| `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions.
|
|
87
|
-
| `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select.
|
|
88
|
-
| `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive.
|
|
89
|
-
| `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically.
|
|
90
|
-
| `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself).
|
|
91
|
-
| `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet.
|
|
92
|
-
| `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`.
|
|
93
|
-
| `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn).
|
|
94
|
-
| `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance.
|
|
95
|
-
| `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart.
|
|
96
|
-
| `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it.
|
|
97
|
-
| `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one.
|
|
98
|
-
| `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children.
|
|
99
|
-
| `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks.
|
|
100
|
-
| `
|
|
82
|
+
| Import | What it is |
|
|
83
|
+
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `page-header` | The only page-title pattern: optional `breadcrumbs` snippet, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
|
|
85
|
+
| `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
|
|
86
|
+
| `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
|
|
87
|
+
| `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. |
|
|
88
|
+
| `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
|
|
89
|
+
| `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
|
|
90
|
+
| `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
|
|
91
|
+
| `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
|
|
92
|
+
| `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
|
|
93
|
+
| `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
|
|
94
|
+
| `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
|
|
95
|
+
| `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
|
|
96
|
+
| `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
|
|
97
|
+
| `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
|
|
98
|
+
| `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
|
|
99
|
+
| `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
|
|
100
|
+
| `schema-form` | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see [Server-described forms](#server-described-forms) below. |
|
|
101
|
+
| `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
|
|
102
|
+
| `library-browse` | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (`LibraryDocument[]`, `LibraryFacet[]`). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is `schema-form` because its shape changes (#30). |
|
|
103
|
+
| `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
|
|
104
|
+
| `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
|
|
105
|
+
| `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
|
|
101
106
|
|
|
102
107
|
**The application shell** (`app-shell`, `command-palette`). Page chrome is not
|
|
103
108
|
what makes an app feel like an app either — the shell is. Five household
|
|
@@ -503,6 +508,145 @@ but the same class on the `<th>` collapses the heading over its neighbour:
|
|
|
503
508
|
{ accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }
|
|
504
509
|
```
|
|
505
510
|
|
|
511
|
+
## Server-described forms
|
|
512
|
+
|
|
513
|
+
`<SchemaForm>` renders a config object whose shape arrives at runtime. It is the
|
|
514
|
+
estate's one mechanism for that, and this README is where its contract lives.
|
|
515
|
+
|
|
516
|
+
Two standard documents go in. A **JSON Schema** says what the value is; a **JSON
|
|
517
|
+
Forms UI Schema** says how it is laid out — a tree of layouts, Controls
|
|
518
|
+
addressed by JSON Pointer `scope`, and declarative `rule: { effect, condition }`
|
|
519
|
+
for conditional visibility. Both are published standards, which is the point:
|
|
520
|
+
the renderer is replaceable without a server changing anything.
|
|
521
|
+
|
|
522
|
+
```svelte
|
|
523
|
+
<script lang="ts">
|
|
524
|
+
import SchemaForm from '@poodle64/ui/schema-form';
|
|
525
|
+
|
|
526
|
+
let { schema, uischema } = $props(); // fetched by the app, not by this package
|
|
527
|
+
let value = $state({});
|
|
528
|
+
</script>
|
|
529
|
+
|
|
530
|
+
<SchemaForm {schema} {uischema} {value} onChange={(next) => (value = next)} />
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
| Prop | Purpose |
|
|
534
|
+
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
535
|
+
| `schema` | The JSON Schema. Scopes resolve against it, `$ref` included, and it drives validation. |
|
|
536
|
+
| `uischema` | The JSON Forms UI Schema. Omit it and one is generated from the schema, the only mode in which no field can be missing from the layout. |
|
|
537
|
+
| `value` | The current value. The component is controlled and never mutates what it is given. |
|
|
538
|
+
| `onChange` | `(next, { path, value })`. `next` is a fresh object with every untouched branch structurally intact. |
|
|
539
|
+
| `disabled` | Disables every control. |
|
|
540
|
+
| `idPrefix` | Prefix for generated element ids, when two forms share a page. |
|
|
541
|
+
|
|
542
|
+
### It knows nothing about any app
|
|
543
|
+
|
|
544
|
+
No HTTP client, no `fetch`, no endpoint string, no service name, no
|
|
545
|
+
app-specific type. Consumers fetch the two documents and map the result
|
|
546
|
+
themselves. That invariant is load-bearing: it is what lets the engine behind
|
|
547
|
+
the schema be swapped later without touching a consumer, and what stopped three
|
|
548
|
+
apps' renderers from being three different components.
|
|
549
|
+
|
|
550
|
+
### An unrecognised control renders LOUDLY
|
|
551
|
+
|
|
552
|
+
**This is the non-negotiable rule.** The three hand-rolled renderers this
|
|
553
|
+
replaces failed silently: a `widget: "dropdown"` hint rendered no input at all,
|
|
554
|
+
three whole top-level config groups never rendered because their names were
|
|
555
|
+
absent from a hardcoded `GROUP_ORDER`, and nested hints were inert. Every one
|
|
556
|
+
of those made a field VANISH, and a form with a field missing looks exactly like
|
|
557
|
+
a form. They shipped that way for months.
|
|
558
|
+
|
|
559
|
+
So nothing here renders as nothing. Eight failure shapes each produce a
|
|
560
|
+
visibly-flagged block carrying `data-schema-form-unknown` and a
|
|
561
|
+
`data-unknown-reason`, which names what could not be done, quotes the pointer so
|
|
562
|
+
the fix is a copy-paste, shows the value that would otherwise have been lost,
|
|
563
|
+
and keeps it editable wherever a text box cannot destroy structure:
|
|
564
|
+
|
|
565
|
+
| `data-unknown-reason` | Raised when |
|
|
566
|
+
| --------------------- | --------------------------------------------------------------------------------- |
|
|
567
|
+
| `unknown-widget` | `options.format` / `options.widget` names a widget this package does not ship. |
|
|
568
|
+
| `unknown-element` | A UI schema element `type` outside the vocabulary below. |
|
|
569
|
+
| `unresolved-scope` | A Control's `scope` resolves to nothing in the JSON Schema. |
|
|
570
|
+
| `missing-scope` | A Control carries no `scope` at all. |
|
|
571
|
+
| `no-options` | A `select` or `radio` over a subschema that declares no `enum` or `oneOf`. |
|
|
572
|
+
| `object-control` | A Control points at an object, which needs a layout rather than one control. |
|
|
573
|
+
| `unsupported-array` | An array whose items are not primitives, so `tags` cannot represent it. |
|
|
574
|
+
| `not-in-layout` | The JSON Schema describes a property no Control anywhere in the layout addresses. |
|
|
575
|
+
|
|
576
|
+
A primitive value stays editable; an object or an array is shown read-only,
|
|
577
|
+
because a text box over structured data is a data-loss affordance rather than a
|
|
578
|
+
fallback. An unrecognised hint is never quietly swapped for the widget it
|
|
579
|
+
probably meant — `dropdown` almost certainly meant `select`, and guessing would
|
|
580
|
+
restore the field while hiding the fact that the two documents disagree.
|
|
581
|
+
|
|
582
|
+
`not-in-layout` is reported once per unaddressed subtree, at the subtree, so a
|
|
583
|
+
forgotten group is one loud entry rather than forty. There is no prop to silence
|
|
584
|
+
any of this. The way to stop a field being flagged is to put a Control for it in
|
|
585
|
+
the UI schema.
|
|
586
|
+
|
|
587
|
+
`src/test/schema-form-loud-unknown.test.ts` pins all eight reasons, and
|
|
588
|
+
`harness/drive.mjs` proves in a real engine that each flag has real size and a
|
|
589
|
+
resolved, visible warning border. "Renders loudly" is a claim about paint, and
|
|
590
|
+
a fallback styled into invisibility would satisfy every jsdom assertion while
|
|
591
|
+
reproducing the original defect exactly.
|
|
592
|
+
|
|
593
|
+
### The widget dispatch table
|
|
594
|
+
|
|
595
|
+
An explicit hint wins; otherwise the widget is derived from the subschema.
|
|
596
|
+
`options.format` is the JSON Forms spelling and `options.widget` is accepted
|
|
597
|
+
alongside it, so a server migrating off a home-grown hint vocabulary does not
|
|
598
|
+
have to change both documents at once. Any hint outside this table is
|
|
599
|
+
`unknown-widget`.
|
|
600
|
+
|
|
601
|
+
| Widget | Chosen when | Rendered by |
|
|
602
|
+
| ---------- | ----------------------------------------------- | --------------------------------- |
|
|
603
|
+
| `text` | `string` (the default) | `input` |
|
|
604
|
+
| `textarea` | `options.multi: true`, or the hint | `textarea` |
|
|
605
|
+
| `password` | `format: "password"`, or the hint | `input[type=password]` |
|
|
606
|
+
| `date` | `format: "date"` | `input[type=date]` |
|
|
607
|
+
| `time` | `format: "time"` | `input[type=time]` |
|
|
608
|
+
| `datetime` | `format: "date-time"` | `input[type=datetime-local]` |
|
|
609
|
+
| `number` | `number` / `integer` | `input[type=number]` |
|
|
610
|
+
| `slider` | `options.slider: true` on a number, or the hint | composed here (native `range`) |
|
|
611
|
+
| `select` | `enum`, or `oneOf: [{ const, title }]` | `select` |
|
|
612
|
+
| `radio` | the hint, on the same closed value sets | composed here (native `radio`) |
|
|
613
|
+
| `switch` | `boolean` (the default) | `switch` |
|
|
614
|
+
| `checkbox` | the hint, on a boolean | `checkbox` |
|
|
615
|
+
| `tags` | `array` of `string` / `number` / `integer` | composed here (`badge` + `input`) |
|
|
616
|
+
|
|
617
|
+
Three of those — `slider`, `radio` and `tags` — had no primitive in this package
|
|
618
|
+
and are composed inside `schema-form/widgets/`. They are deliberately not
|
|
619
|
+
top-level exports: their only caller is the renderer, and a widget promoted to
|
|
620
|
+
the catalogue before a second consumer wants it is a shape nobody agreed to.
|
|
621
|
+
|
|
622
|
+
### The layout vocabulary
|
|
623
|
+
|
|
624
|
+
`VerticalLayout`, `HorizontalLayout`, `Group` (a `Panel`), `Categorization` /
|
|
625
|
+
`Category` (a `Tabs` set), `Control` and `Label`. Anything else is
|
|
626
|
+
`unknown-element`.
|
|
627
|
+
|
|
628
|
+
`rule` is evaluated on every element, not only on Controls, so a rule on a Group
|
|
629
|
+
takes the whole group with it — which is what an author writing one means.
|
|
630
|
+
`SHOW` / `HIDE` decide whether the element renders at all; `ENABLE` / `DISABLE`
|
|
631
|
+
and `options.readonly` disable the control instead.
|
|
632
|
+
|
|
633
|
+
### The engine
|
|
634
|
+
|
|
635
|
+
`@jsonforms/core` is used **headless**: JSON Pointer scope resolution, rule
|
|
636
|
+
evaluation and the Ajv instance, nothing else. The renderers are this package's
|
|
637
|
+
own and dispatch to this package's own widgets. There is no official Svelte
|
|
638
|
+
renderer for JSON Forms and the community one carries no external validation, so
|
|
639
|
+
neither is used.
|
|
640
|
+
|
|
641
|
+
JSON Forms was chosen over RJSF and `@sjsf/form` on one deciding fact: the
|
|
642
|
+
upstream config models carry 36 conditional-visibility rules, and JSON Forms'
|
|
643
|
+
`rule` model maps onto them 1:1, while RJSF has no standard conditional-
|
|
644
|
+
visibility directive. `@jsonforms/core@3.8.0` was verified working headless
|
|
645
|
+
under Svelte 5 in this package before it was adopted, on 21/08/2026 — scope
|
|
646
|
+
resolution through `$ref`, `SHOW`/`HIDE`/`DISABLE` evaluation, and
|
|
647
|
+
`Generate.uiSchema` all exercised live. It is the package's only runtime
|
|
648
|
+
dependency beyond what was already here.
|
|
649
|
+
|
|
506
650
|
## Consuming the package
|
|
507
651
|
|
|
508
652
|
Published to public npm under the `@poodle64` scope, same as `@poodle64/design-tokens`
|
|
@@ -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,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>
|