@poodle64/ui 2026.10.4 → 2026.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +100 -4
  2. package/dist/components/ui/context-column/context-column.svelte +118 -21
  3. package/dist/components/ui/context-column/context-column.svelte.d.ts +24 -1
  4. package/dist/components/ui/document-pane/document-pane.svelte +136 -0
  5. package/dist/components/ui/document-pane/document-pane.svelte.d.ts +43 -0
  6. package/dist/components/ui/document-pane/index.d.ts +2 -0
  7. package/dist/components/ui/document-pane/index.js +2 -0
  8. package/dist/components/ui/ledger/head-cell.svelte +73 -0
  9. package/dist/components/ui/ledger/head-cell.svelte.d.ts +19 -0
  10. package/dist/components/ui/ledger/index.d.ts +1 -1
  11. package/dist/components/ui/ledger/ledger-columns.svelte +29 -21
  12. package/dist/components/ui/ledger/ledger-columns.svelte.d.ts +50 -13
  13. package/dist/components/ui/ledger/ledger.d.ts +6 -0
  14. package/dist/components/ui/ledger/ledger.js +9 -1
  15. package/dist/components/ui/ledger/ledger.svelte +58 -78
  16. package/dist/components/ui/ledger/ledger.svelte.d.ts +6 -1
  17. package/dist/components/ui/ledger/look.d.ts +32 -0
  18. package/dist/components/ui/ledger/look.js +32 -0
  19. package/dist/components/ui/ledger/types.d.ts +21 -11
  20. package/dist/components/ui/list-toolbar/icon-action.svelte +42 -0
  21. package/dist/components/ui/list-toolbar/icon-action.svelte.d.ts +7 -0
  22. package/dist/components/ui/list-toolbar/index.d.ts +3 -0
  23. package/dist/components/ui/list-toolbar/index.js +2 -0
  24. package/dist/components/ui/list-toolbar/list-toolbar.svelte +85 -0
  25. package/dist/components/ui/list-toolbar/list-toolbar.svelte.d.ts +32 -0
  26. package/dist/components/ui/list-toolbar/types.d.ts +18 -0
  27. package/dist/components/ui/list-toolbar/types.js +1 -0
  28. package/dist/components/ui/panel/panel.svelte +8 -1
  29. package/dist/components/ui/panel/panel.svelte.d.ts +6 -0
  30. package/dist/components/ui/record-list/index.d.ts +3 -0
  31. package/dist/components/ui/record-list/index.js +2 -0
  32. package/dist/components/ui/record-list/record-list.svelte +489 -0
  33. package/dist/components/ui/record-list/record-list.svelte.d.ts +89 -0
  34. package/dist/components/ui/record-list/thumbnail.svelte +32 -0
  35. package/dist/components/ui/record-list/thumbnail.svelte.d.ts +14 -0
  36. package/dist/components/ui/record-list/types.d.ts +40 -0
  37. package/dist/components/ui/record-list/types.js +1 -0
  38. package/dist/components/ui/record-switcher/index.d.ts +3 -0
  39. package/dist/components/ui/record-switcher/index.js +2 -0
  40. package/dist/components/ui/record-switcher/record-switcher.svelte +167 -0
  41. package/dist/components/ui/record-switcher/record-switcher.svelte.d.ts +28 -0
  42. package/dist/components/ui/record-switcher/types.d.ts +22 -0
  43. package/dist/components/ui/record-switcher/types.js +1 -0
  44. package/package.json +1 -1
  45. package/registry/component-map.json +34 -6
  46. package/registry/component-map.md +8 -4
package/README.md CHANGED
@@ -90,10 +90,14 @@ rather than rebuilding it:
90
90
  | Import | What it is |
91
91
  | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
92
  | `page-header` | The only page-title pattern: optional `breadcrumbs` and `icon` snippets, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
93
- | `panel` | The generic titled card, and the settings section: optional icon, a clamped one-line `subtitle` or a wrapping `description`, trailing `action`s, a body that can opt out of padding, an optional `footer` strip for Save/Cancel, and `tone="destructive"` for a danger zone. There is no `SettingsSection`; see [The settings destination](#the-settings-destination). |
93
+ | `panel` | The generic titled card, and the settings section: optional icon, a clamped one-line `subtitle` or a wrapping `description`, trailing `action`s, a body that can opt out of padding or scroll inside the card (`scroll`), an optional `footer` strip for Save/Cancel, and `tone="destructive"` for a danger zone. There is no `SettingsSection`; see [The settings destination](#the-settings-destination). |
94
+ | `list-toolbar` | The bar above a list, on the page ground: title, count, icon actions named by their tooltips, one labelled action. The Ledger and the RecordList draw theirs with it. |
95
+ | `record-list` | The ledger's look for a list that is not money. See [Lists that are not money](#lists-that-are-not-money). |
96
+ | `record-switcher` | The open record's whole name as the top bar's control, its menu finding, grouping and picturing every record, and the record's pages as tabs. |
97
+ | `document-pane` | One document beside what it belongs to: titled, closable, its page fitted, with paging and zoom (a `PageCanvas` in a `Panel`). |
94
98
  | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
95
99
  | `settings-shell` | The settings destination: a grouped section list of `NavItem` rows beside the section you are reading, both scrolling independently from `md`, stacked below it. Render it inside `AppShell` with `padded={false}`. |
96
- | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. Below `xl` (1280px), where the column has nowhere to sit, the same content opens from a floating trigger instead of disappearing. |
100
+ | `context-column` | The persistent right-hand column. A list page's: a standing `StatList` plus an optional detail that flows in on select. A record page's (`title`, `children`): the one opened item, closable, scrolling inside itself, and its `documentPane` beside it once the page's row has room. Below `xl` (1280px) the same content opens from a floating trigger instead of disappearing. See [A record's page](#a-records-page). |
97
101
  | `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. |
98
102
  | `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
99
103
  | `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
@@ -887,11 +891,11 @@ no module rebuilds them and a need one module finds becomes a toggle for all.
887
891
  | Prop | Purpose |
888
892
  | --- | --- |
889
893
  | `rows` | `LedgerRow`s, extended with whatever the module's columns read: `id`, `date` (ISO), `title`, `note`, `amount` (signed), `balance`, `review` (`true` or a `Status`) with `reviewLabel` (the chip's words, "Review" by default), `conversion` (a swap inside the account: plain amount, in no total), `attachment` (its name), `origins` (a synthetic row's statement lines). Pass every row the view covers, filtered: the ledger sorts newest first, keeps a day's rows in the order given, and mounts only those near the view. |
890
- | `columns` | The module's columns, in order after the amount. The keys `date`, `title`, `amount` and `balance` are the ledger's own cells: list one to rename or resize it (the title reads "Description" until the module names it), and list `balance` to offer the running balance at all, with `text` saying what it counts (money, a coin, units). A `cell` snippet's links and buttons take their own clicks; the rest of the cell opens the row. A column's `sort` gives the real value its head sorts by (a number, not its text); a `text` column sorts by its text without one, a `cell`-only column does not sort, and the balance never does. |
894
+ | `columns` | The module's columns, in order after the amount. The keys `date`, `title`, `amount` and `balance` are the ledger's own cells: list one to rename or resize it (the title reads "Description" until the module names it), and list `balance` to offer the running balance at all, with `text` saying what it counts (money, a coin, units). A column `on: 'wide'` is ticked only on a wide ledger. A `cell` snippet's links and buttons take their own clicks; the rest of the cell opens the row. A column's `sort` gives the real value its head sorts by (a number, not its text); a `text` column sorts by its text without one, a `cell`-only column does not sort, and the balance never does. |
891
895
  | `preferences`, `onPreferencesChange` | The viewer's `{ columns, period }`. Missing fields take the module's defaults (each column's `on`, the balance off, grouped by month); an unknown column key is ignored. The ledger stores nothing. |
892
896
  | `fyStart` | The month a financial year starts, 1–12. July by default; anything else groups from July and logs an error in development. |
893
897
  | `locale`, `currency` | Every figure, amounts, nets and counts, in this locale (`AU_LOCALE` by default) and currency (AUD). Dates keep the drawn "15 Sep": the ledger's words are English. |
894
- | `title`, `meta`, `actions` | The toolbar on the page ground: its title, the count beside it, and the module's own actions after the Columns menu. |
898
+ | `title`, `meta`, `actions`, `tools`, `action` | The bar on the page ground, a `ListToolbar`: its title (a heading), the count beside it, the module's own controls after the Columns menu, its icon actions (each named by its tooltip) and its one labelled action. |
895
899
  | `selected`, `bulkActions` | Ticking exists only with `bulkActions`, whose snippet gets the ticked rows in the bar that replaces the toolbar. |
896
900
  | `open`, `editor` | Opening exists only with `editor`: the row lifts out of its card as its own surface, holding the snippet. Its second argument carries the row's `template` and each column's grid line, so fields can sit under their columns. |
897
901
  | `originActions`, `onAttachment` | Split, join and undo under a synthetic row's statement lines; the paperclip as a button. |
@@ -940,6 +944,98 @@ row in about 300ms with some 20 mounted, where mounting them all took 7.6 to 8.6
940
944
  seconds and 65,000 nodes. Its look is measured in a real engine at 390, 1000,
941
945
  1440 and 3360px, and at 5,000 rows (`harness/drive.md` §"The ledger").
942
946
 
947
+ ## Lists that are not money
948
+
949
+ Properties, managers, tools, the lines of a profit and loss: `RecordList` wears
950
+ the ledger's look, so every list in an app reads one way. The same bar, head
951
+ card, row cards with hairlines and group labels on the ground, drawn from the
952
+ ledger's own classes (`ledger/look.ts`), so the two cannot drift. `DataTable`
953
+ stays for a dense grid of data; a module's own records are a RecordList.
954
+
955
+ ```svelte
956
+ <script lang="ts">
957
+ import RecordList, { type RecordListColumn, type RecordListRow } from '@poodle64/ui/record-list';
958
+ import House from '@lucide/svelte/icons/house';
959
+
960
+ type Property = RecordListRow & { value: number; loan: number };
961
+ const columns: RecordListColumn<Property>[] = [
962
+ { key: 'title', label: 'Property' },
963
+ { key: 'value', label: 'Value', align: 'end', sort: (r) => r.value, text: (r) => aud(r.value) },
964
+ { key: 'loan', label: 'Loan', align: 'end', on: 'wide', text: (r) => aud(r.loan) }
965
+ ];
966
+ </script>
967
+
968
+ <RecordList rows={properties} {columns} title="Owned properties" noun={['property', 'properties']}
969
+ icon={House} href={(r) => `/property/${r.id}`} bind:preferences
970
+ tools={[{ label: 'Download as CSV', icon: Download, onclick: save }]}
971
+ action={{ label: 'Add property', icon: Plus, href: '/property/new' }} />
972
+ ```
973
+
974
+ | Prop | Purpose |
975
+ | --- | --- |
976
+ | `rows` | `RecordListRow`s: `id`, `title`, `note`, `image` (a thumbnail), `icon` (a row's own tile, for a tool), `flags` (what wants the viewer: the first a chip on line two with "+N more" naming the rest, every one on a phone). |
977
+ | `columns` | The module's columns after the title, the Ledger's `ListColumn` shape plus `total`. List `title` to rename, resize or draw it with `cell`. `on: 'wide'` ticks a column only on a wide list (1200px of list), so extra width shows more columns and the viewer can tick it back. |
978
+ | `preferences`, `onPreferencesChange`, `preferencesNote` | The viewer's `{ columns }` from the Columns menu, which the bar carries while the list has heads. The list stores nothing. |
979
+ | `title`, `meta`, `leading`, `toolbar`, `tools`, `action` | The `ListToolbar`. No title, no bar. |
980
+ | `group` | A row's group label: groups show in the order their first rows come, each label on the ground with its count and each column's `total` over its rows. |
981
+ | `totalLabel` | A card after the rows with each column's `total` over every row: "All three". |
982
+ | `head` | `false` drops the head card (and the Columns menu): a tools list. |
983
+ | `icon` | The quiet tile a row without a photo or its own `icon` shows; with it every row has a thumbnail. |
984
+ | `href`, `onOpen`, `open` | A row is a link, or a button that opens a detail beside the list; `open` marks that row. A control in a `cell` takes its own click. |
985
+ | `noun`, `empty`, `footer`, `layout` | As the Ledger's. |
986
+
987
+ A sort orders rows inside their groups, largest first (words from A), then
988
+ reversed, then the list's own order. On a phone (below 600px of list) a row is its thumbnail, title
989
+ and lines, with the first end-aligned column's value beside the title. Measured
990
+ in a real engine at 390, 1440 and 3360px (`harness/drive.mjs`, `?surface=records`).
991
+
992
+ ## A record's page
993
+
994
+ The record's whole name is the top bar's control; its column holds the one item
995
+ the page opened; that item's document stands beside it (`?surface=record`).
996
+
997
+ ```svelte
998
+ <ShellControls>
999
+ <RecordSwitcher name="4. Banksia" current={4} noun="property" icon={House}
1000
+ groups={[{ label: 'Owned', items: owned }, { label: 'Sold', items: sold }]}
1001
+ all={{ label: 'All properties', href: '/property' }}
1002
+ pages={[{ label: 'Overview', href: '/property/4', current: true }, …]} />
1003
+ </ShellControls>
1004
+
1005
+ <div class="flex min-h-0 flex-1 gap-5">
1006
+ <div class="min-w-0 flex-1"><!-- header card, one line a section --></div>
1007
+ <ContextColumn title={opened.title} subtitle="Opened on what needs you"
1008
+ onClose={reopenWhatNeedsYou} documentPane={lease ? leaseDoc : undefined}>
1009
+ <!-- the opened section -->
1010
+ </ContextColumn>
1011
+ </div>
1012
+
1013
+ {#snippet leaseDoc()}
1014
+ <DocumentPane title="Residential tenancy agreement" subtitle={lease.span} pages={lease.pages}
1015
+ actions={[{ label: 'Download', icon: Download, href: lease.url, download: true }]}
1016
+ onClose={() => (lease = null)} />
1017
+ {/snippet}
1018
+ ```
1019
+
1020
+ - **RecordSwitcher** is a raised button, its chevron inside, after the section's
1021
+ name and a "/". Its menu searches by name and note, lists each group under its
1022
+ heading with a thumbnail (`image`, else `icon`'s tile), opens on the open
1023
+ record ticked, and ends on `all`. Every record is a link. `pages` are the
1024
+ record's own tabs. It places nothing itself: put it in `ShellControls` with
1025
+ whatever else the page sets in the bar.
1026
+ - **ContextColumn's record form** holds one item, never none: the page opens it
1027
+ on what needs the viewer first and `onClose` reopens that. The item is as tall
1028
+ as what it holds up to the column, then scrolls inside itself; a new `title`
1029
+ swaps it in from the top. Its `documentPane` takes the column's place, and
1030
+ stands beside the column as a third pane once the page's row is 1250px wide,
1031
+ which a 1600px screen gives with the rail open. The row decides, not the
1032
+ screen, so a folded rail or a narrow window is read as the room there is.
1033
+ - **DocumentPane** fills the height it is given: `pages` as one image URL a page,
1034
+ or `page` and `pageCount` to draw them yourself, as `PageCanvas` takes them. A
1035
+ page that fails to load says so and keeps its place. Another document swapped
1036
+ into the same pane opens on its first page, fitted; so does one the column
1037
+ moves beside itself as the row crosses 1250px.
1038
+
943
1039
  ## Hand-written forms
944
1040
 
945
1041
  The shadcn-svelte Formsnap wrapper set, over `sveltekit-superforms`:
@@ -5,13 +5,30 @@
5
5
  import StatList from '../stat-list/stat-list.svelte';
6
6
  import type { StatItem } from '../stat-list/stat-list.svelte';
7
7
  import AppDialog from '../app-dialog/app-dialog.svelte';
8
+ import Panel from '../panel/panel.svelte';
9
+ import * as Tooltip from '../tooltip/index.js';
10
+ import IconAction from '../list-toolbar/icon-action.svelte';
8
11
  import PanelRight from '@lucide/svelte/icons/panel-right';
12
+ import X from '@lucide/svelte/icons/x';
9
13
 
10
- // The persistent right-hand context column, identical on every route. It holds
11
- // one standing "At a glance" stat card and, on routes with a selectable table,
12
- // the selected-row detail beneath it (a DetailPanel passed as the `detail`
13
- // snippet). The stat card stays put; the detail flows in on select and leaves
14
- // on deselect, so the column never changes shape.
14
+ // The persistent right-hand context column, identical on every route, in
15
+ // one of two forms.
16
+ //
17
+ // A list page's: one standing "At a glance" stat card and, beneath it, a
18
+ // Find or the selected row's detail (the `detail` snippet). The stat card
19
+ // stays put; the detail flows in on select and leaves on deselect, so the
20
+ // column never changes shape.
21
+ //
22
+ // A record page's (`title` and `children`): the one item the page has
23
+ // opened, a card with its title and a close button whose body scrolls
24
+ // inside it, as tall as what it holds up to the column. A click elsewhere
25
+ // on the page swaps it, and it is never empty: the page opens it on what
26
+ // needs the viewer first, and a close reopens that. An opened item's
27
+ // document (`documentPane`, a DocumentPane) takes the column's place, and
28
+ // stands beside it as a third pane once the page's row has room for three:
29
+ // 1250px of row, which a 1600px screen gives with the rail open. The row,
30
+ // not the screen, decides, so a folded rail or a narrow window is read
31
+ // as the room the page actually has.
15
32
  //
16
33
  // Width is clamped (shared-design-language §6) so a wide screen earns
17
34
  // information, not emptiness. The column is a flex child of the page body row;
@@ -20,50 +37,130 @@
20
37
  //
21
38
  // Below xl (1280px) a fixed nav rail plus a ~400px column would crush the
22
39
  // primary pane, so the standing `<aside>` stays hidden there — but its content
23
- // does not vanish with it. A floating trigger opens the identical stats/detail
24
- // pair in a dialogue instead: the column's shape is a desktop affordance, the
40
+ // does not vanish with it. A floating trigger opens the identical content in a
41
+ // dialogue instead: the column's shape is a desktop affordance, the
25
42
  // information inside it is not.
43
+ type ListForm = {
44
+ stats: StatItem[];
45
+ statsTitle?: string;
46
+ statsInfo?: string;
47
+ detail?: Snippet;
48
+ title?: never;
49
+ subtitle?: never;
50
+ onClose?: never;
51
+ children?: never;
52
+ documentPane?: never;
53
+ };
54
+ type RecordForm = {
55
+ /** The opened item's name. */
56
+ title: string;
57
+ /** One line under it: why it is the one open. */
58
+ subtitle?: string;
59
+ /** Shows a close button. Reopen what needs the viewer: the column is never empty. */
60
+ onClose?: () => void;
61
+ /** The opened item. */
62
+ children: Snippet;
63
+ /** The opened item's document: a DocumentPane, in the column's place or beside it. */
64
+ documentPane?: Snippet;
65
+ stats?: never;
66
+ statsTitle?: never;
67
+ statsInfo?: never;
68
+ detail?: never;
69
+ };
70
+
26
71
  let {
27
72
  stats,
28
73
  statsTitle = 'At a glance',
29
74
  statsInfo,
30
75
  detail,
76
+ title,
77
+ subtitle,
78
+ onClose,
79
+ children,
80
+ documentPane,
31
81
  /** The landmark's accessible name. `<aside>` with no name is exposed as
32
82
  "complementary" alone — set this to identify which one. */
33
83
  ariaLabel,
34
84
  ref = $bindable(null),
35
85
  class: className,
36
86
  ...restProps
37
- }: WithElementRef<HTMLAttributes<HTMLElement>> & {
38
- stats: StatItem[];
39
- statsTitle?: string;
40
- statsInfo?: string;
41
- detail?: Snippet;
42
- ariaLabel?: string;
43
- } = $props();
87
+ }: Omit<WithElementRef<HTMLAttributes<HTMLElement>>, 'title' | 'children'> &
88
+ (ListForm | RecordForm) & { ariaLabel?: string } = $props();
44
89
 
45
90
  let sheetOpen = $state(false);
91
+ /**
92
+ * The page row the column stands in, by its width; null until read, so a
93
+ * document is placed once rather than mounted in the column and moved.
94
+ * Crossing the threshold later moves it, opening it afresh.
95
+ */
96
+ let row = $state<number | null>(null);
97
+ $effect(() => {
98
+ const parent = ref?.parentElement;
99
+ if (!parent) return;
100
+ row = parent.clientWidth;
101
+ if (typeof ResizeObserver === 'undefined') return;
102
+ const observer = new ResizeObserver(([entry]) => (row = entry?.contentRect.width ?? 0));
103
+ observer.observe(parent);
104
+ return () => observer.disconnect();
105
+ });
106
+ /** Room for the document beside the column, not in its place. */
107
+ const BESIDE = 1250;
108
+ const beside = $derived(row !== null && row >= BESIDE);
109
+ const name = $derived(ariaLabel ?? title ?? statsTitle);
46
110
  </script>
47
111
 
112
+ {#snippet close()}
113
+ <Tooltip.Provider delayDuration={150}>
114
+ <IconAction action={{ label: `Close ${title}`, icon: X, onclick: onClose }} />
115
+ </Tooltip.Provider>
116
+ {/snippet}
117
+
118
+ {#snippet item()}
119
+ {#key title}
120
+ <Panel
121
+ {title}
122
+ {subtitle}
123
+ action={onClose ? close : undefined}
124
+ pad={false}
125
+ scroll
126
+ class="max-h-full min-h-0"
127
+ data-slot="context-column-item"
128
+ >
129
+ {@render children?.()}
130
+ </Panel>
131
+ {/key}
132
+ {/snippet}
133
+
48
134
  {#snippet body()}
49
- <StatList items={stats} title={statsTitle} info={statsInfo} />
50
- {#if detail}
51
- {@render detail()}
135
+ {#if children}
136
+ {#if documentPane && row !== null && !beside}{@render documentPane()}{:else}{@render item()}{/if}
137
+ {:else}
138
+ <StatList items={stats ?? []} title={statsTitle} info={statsInfo} />
139
+ {#if detail}
140
+ {@render detail()}
141
+ {/if}
52
142
  {/if}
53
143
  {/snippet}
54
144
 
55
145
  <aside
56
146
  bind:this={ref}
57
- aria-label={ariaLabel}
147
+ aria-label={ariaLabel ?? title}
58
148
  class={cn(
59
149
  'hidden max-h-full min-h-0 w-[clamp(360px,28vw,460px)] shrink-0 flex-col gap-5 xl:flex',
60
150
  className
61
151
  )}
152
+ data-slot="context-column"
62
153
  {...restProps}
63
154
  >
64
155
  {@render body()}
65
156
  </aside>
66
157
 
158
+ {#if children && documentPane && beside}
159
+ <div class="hidden min-h-0 min-w-0 flex-1 flex-col xl:flex" data-slot="context-column-document">
160
+ {@render documentPane()}
161
+ </div>
162
+ {/if}
163
+
67
164
  <!-- The reachable stand-in below xl. `size-11` (44px) rather than this
68
165
  package's usual 36px control row: this is the one control whose entire
69
166
  job is making otherwise-unreachable content reachable, so it earns the
@@ -72,13 +169,13 @@
72
169
  type="button"
73
170
  onclick={() => (sheetOpen = true)}
74
171
  class="border-border bg-card text-muted-foreground hover:text-foreground ds-edge fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-30 grid size-11 flex-none place-items-center rounded-full border xl:hidden"
75
- aria-label={ariaLabel ? `Open ${ariaLabel}` : `Open ${statsTitle}`}
172
+ aria-label="Open {name}"
76
173
  >
77
174
  <PanelRight class="size-4.5" />
78
175
  </button>
79
176
 
80
- <AppDialog bind:open={sheetOpen} title={ariaLabel ?? statsTitle} size="sm">
81
- <div class="flex flex-col gap-5">
177
+ <AppDialog bind:open={sheetOpen} title={name} size="sm">
178
+ <div class={cn('flex flex-col gap-5', children && documentPane && 'h-[70dvh]')}>
82
179
  {@render body()}
83
180
  </div>
84
181
  </AppDialog>
@@ -2,11 +2,34 @@ import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { type WithElementRef } from '../../../utils.js';
4
4
  import type { StatItem } from '../stat-list/stat-list.svelte';
5
- type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
5
+ type ListForm = {
6
6
  stats: StatItem[];
7
7
  statsTitle?: string;
8
8
  statsInfo?: string;
9
9
  detail?: Snippet;
10
+ title?: never;
11
+ subtitle?: never;
12
+ onClose?: never;
13
+ children?: never;
14
+ documentPane?: never;
15
+ };
16
+ type RecordForm = {
17
+ /** The opened item's name. */
18
+ title: string;
19
+ /** One line under it: why it is the one open. */
20
+ subtitle?: string;
21
+ /** Shows a close button. Reopen what needs the viewer: the column is never empty. */
22
+ onClose?: () => void;
23
+ /** The opened item. */
24
+ children: Snippet;
25
+ /** The opened item's document: a DocumentPane, in the column's place or beside it. */
26
+ documentPane?: Snippet;
27
+ stats?: never;
28
+ statsTitle?: never;
29
+ statsInfo?: never;
30
+ detail?: never;
31
+ };
32
+ type $$ComponentProps = Omit<WithElementRef<HTMLAttributes<HTMLElement>>, 'title' | 'children'> & (ListForm | RecordForm) & {
10
33
  ariaLabel?: string;
11
34
  };
12
35
  declare const ContextColumn: import("svelte").Component<$$ComponentProps, {}, "ref">;
@@ -0,0 +1,136 @@
1
+ <script lang="ts">
2
+ /**
3
+ * One document beside what it belongs to: titled, closable, its page
4
+ * fitted to the pane, with paging and zoom. The record page's third pane
5
+ * (ContextColumn's `document`), the ledger's document, the Fat
6
+ * Controller's page. It fills the height it is given.
7
+ *
8
+ * The pages are image URLs, one a page; or draw them yourself through
9
+ * `page` with `pageCount`, as PageCanvas takes them (a PDF canvas).
10
+ *
11
+ * <DocumentPane title="Residential tenancy agreement"
12
+ * subtitle="House · 19 Jul 2025 to 18 Jan 2026" pages={leasePages}
13
+ * actions={[{ label: 'Download', icon: Download, href: leaseUrl, download: true }]}
14
+ * onClose={() => (lease = null)} />
15
+ */
16
+ import { untrack, type Snippet } from 'svelte';
17
+ import { SvelteSet } from 'svelte/reactivity';
18
+ import X from '@lucide/svelte/icons/x';
19
+ import Panel from '../panel/panel.svelte';
20
+ import PageCanvas from '../page-canvas/page-canvas.svelte';
21
+ import type { PageCanvasRenderInfo } from '../page-canvas/types.js';
22
+ import * as Tooltip from '../tooltip/index.js';
23
+ import IconAction from '../list-toolbar/icon-action.svelte';
24
+ import type { ListIconAction } from '../list-toolbar/types.js';
25
+ import { cn } from '../../../utils.js';
26
+
27
+ let {
28
+ title,
29
+ subtitle,
30
+ pages,
31
+ pageCount,
32
+ page,
33
+ currentPage = $bindable(1),
34
+ zoom = $bindable(1),
35
+ actions = [],
36
+ onClose,
37
+ class: className
38
+ }: {
39
+ title: string;
40
+ /** One line under the title: what the document is of, and when. */
41
+ subtitle?: string;
42
+ /** 1-based. */
43
+ currentPage?: number;
44
+ zoom?: number;
45
+ /** Icon actions in the header, each named by its tooltip: download, open in a tab. */
46
+ actions?: ListIconAction[];
47
+ /** Shows a close button, last in the header. */
48
+ onClose?: () => void;
49
+ class?: string;
50
+ } & (
51
+ | {
52
+ /** One image URL a page. */
53
+ pages: readonly string[];
54
+ pageCount?: never;
55
+ page?: never;
56
+ }
57
+ | {
58
+ pages?: never;
59
+ pageCount: number;
60
+ /** Draws one page at the size it is handed, and reports its own size once known. */
61
+ page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
62
+ }
63
+ ) = $props();
64
+
65
+ const count = $derived(pages ? pages.length : (pageCount ?? 0));
66
+ /** Pages whose image did not load. */
67
+ const failed = new SvelteSet<number>();
68
+ /**
69
+ * Which document this is. Another one opens on its first page, fitted, with
70
+ * nothing failed: the pane is kept when the page swaps the document in it.
71
+ */
72
+ const identity = $derived(pages ? pages.join('\n') : `${title}\n${pageCount}`);
73
+ let opened = untrack(() => identity);
74
+ $effect.pre(() => {
75
+ if (identity === opened) return;
76
+ opened = identity;
77
+ currentPage = 1;
78
+ zoom = 1;
79
+ failed.clear();
80
+ });
81
+ </script>
82
+
83
+ {#snippet image(n: number, info: PageCanvasRenderInfo)}
84
+ {#if failed.has(n)}
85
+ <div
86
+ class="bg-card border-border text-muted-foreground grid size-full place-items-center rounded-sm border p-4 text-center text-sm"
87
+ >
88
+ Page {n} could not be loaded
89
+ </div>
90
+ {:else}
91
+ <img
92
+ src={pages![n - 1]}
93
+ alt="{title}, page {n} of {count}"
94
+ class="border-border block size-full rounded-sm border object-contain"
95
+ onload={(e) => {
96
+ const img = e.currentTarget as HTMLImageElement;
97
+ info.reportSize(img.naturalWidth, img.naturalHeight);
98
+ }}
99
+ onerror={() => {
100
+ failed.add(n);
101
+ // A page's place keeps A4's proportions, so the pane still pages past it.
102
+ info.reportSize(210, 297);
103
+ }}
104
+ />
105
+ {/if}
106
+ {/snippet}
107
+
108
+ {#snippet header()}
109
+ <Tooltip.Provider delayDuration={150}>
110
+ {#each actions as a (a.label)}<IconAction action={a} />{/each}
111
+ {#if onClose}
112
+ <IconAction action={{ label: `Close ${title}`, icon: X, onclick: onClose }} />
113
+ {/if}
114
+ </Tooltip.Provider>
115
+ {/snippet}
116
+
117
+ <Panel
118
+ {title}
119
+ {subtitle}
120
+ action={actions.length || onClose ? header : undefined}
121
+ pad={false}
122
+ scroll
123
+ class={cn('h-full min-h-0', className)}
124
+ aria-label={title}
125
+ data-slot="document-pane"
126
+ >
127
+ {#key identity}
128
+ <PageCanvas
129
+ pageCount={count}
130
+ page={pages ? image : page!}
131
+ bind:currentPage
132
+ bind:zoom
133
+ class="bg-surface-2 h-full"
134
+ />
135
+ {/key}
136
+ </Panel>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * One document beside what it belongs to: titled, closable, its page
3
+ * fitted to the pane, with paging and zoom. The record page's third pane
4
+ * (ContextColumn's `document`), the ledger's document, the Fat
5
+ * Controller's page. It fills the height it is given.
6
+ *
7
+ * The pages are image URLs, one a page; or draw them yourself through
8
+ * `page` with `pageCount`, as PageCanvas takes them (a PDF canvas).
9
+ *
10
+ * <DocumentPane title="Residential tenancy agreement"
11
+ * subtitle="House · 19 Jul 2025 to 18 Jan 2026" pages={leasePages}
12
+ * actions={[{ label: 'Download', icon: Download, href: leaseUrl, download: true }]}
13
+ * onClose={() => (lease = null)} />
14
+ */
15
+ import { type Snippet } from 'svelte';
16
+ import type { PageCanvasRenderInfo } from '../page-canvas/types.js';
17
+ import type { ListIconAction } from '../list-toolbar/types.js';
18
+ type $$ComponentProps = {
19
+ title: string;
20
+ /** One line under the title: what the document is of, and when. */
21
+ subtitle?: string;
22
+ /** 1-based. */
23
+ currentPage?: number;
24
+ zoom?: number;
25
+ /** Icon actions in the header, each named by its tooltip: download, open in a tab. */
26
+ actions?: ListIconAction[];
27
+ /** Shows a close button, last in the header. */
28
+ onClose?: () => void;
29
+ class?: string;
30
+ } & ({
31
+ /** One image URL a page. */
32
+ pages: readonly string[];
33
+ pageCount?: never;
34
+ page?: never;
35
+ } | {
36
+ pages?: never;
37
+ pageCount: number;
38
+ /** Draws one page at the size it is handed, and reports its own size once known. */
39
+ page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
40
+ });
41
+ declare const DocumentPane: import("svelte").Component<$$ComponentProps, {}, "currentPage" | "zoom">;
42
+ type DocumentPane = ReturnType<typeof DocumentPane>;
43
+ export default DocumentPane;
@@ -0,0 +1,2 @@
1
+ export { default as DocumentPane } from './document-pane.svelte';
2
+ export { default } from './document-pane.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as DocumentPane } from './document-pane.svelte';
2
+ export { default } from './document-pane.svelte';
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ /**
3
+ * One column's head in a head card. A sortable column's head is a button:
4
+ * a click sorts (largest or newest first, unless `first` says otherwise),
5
+ * again reverses, a third time restores the list's own order.
6
+ */
7
+ import ChevronDown from '@lucide/svelte/icons/chevron-down';
8
+ import ChevronUp from '@lucide/svelte/icons/chevron-up';
9
+ import { cn } from '../../../utils.js';
10
+ import { CELL, END, HEAD_TEXT, RING_INSET } from './look.js';
11
+
12
+ let {
13
+ label,
14
+ name,
15
+ align = 'start',
16
+ sortable,
17
+ sort,
18
+ first = 'desc',
19
+ onsort,
20
+ owner,
21
+ class: className
22
+ }: {
23
+ /** What the head says. */
24
+ label: string;
25
+ /** The column's whole name, for the sort button's label. */
26
+ name: string;
27
+ align?: 'start' | 'end';
28
+ sortable: boolean;
29
+ /** This column's sort, when it is the one in force. */
30
+ sort: 'asc' | 'desc' | null;
31
+ /** The direction the first click sorts in. */
32
+ first?: 'asc' | 'desc';
33
+ onsort: () => void;
34
+ /** The `data-slot` prefix: the list that owns the head. */
35
+ owner: string;
36
+ class?: string;
37
+ } = $props();
38
+
39
+ const klass = $derived(cn(CELL, HEAD_TEXT, align === 'end' && END, className));
40
+ </script>
41
+
42
+ {#if sortable}
43
+ <button
44
+ type="button"
45
+ class={cn(
46
+ klass,
47
+ 'group flex cursor-pointer items-center gap-0.5 rounded-md hover:text-foreground',
48
+ RING_INSET,
49
+ align === 'end' && 'justify-end',
50
+ sort && 'text-foreground'
51
+ )}
52
+ aria-label={sort
53
+ ? `Sorted by ${name}, ${sort === 'desc' ? 'descending' : 'ascending'}. Click ${sort === first ? 'to reverse' : 'for the default order'}`
54
+ : `Sort by ${name}`}
55
+ onclick={onsort}
56
+ data-slot="{owner}-sort"
57
+ data-sort={sort ?? undefined}
58
+ >
59
+ <span class="truncate">{label}</span>
60
+ {#if sort === 'asc'}
61
+ <ChevronUp class="size-3 flex-none" />
62
+ {:else}
63
+ <ChevronDown
64
+ class={cn(
65
+ 'size-3 flex-none',
66
+ !sort && 'opacity-0 group-hover:opacity-50 group-focus-visible:opacity-50'
67
+ )}
68
+ />
69
+ {/if}
70
+ </button>
71
+ {:else}
72
+ <span class={klass}>{label}</span>
73
+ {/if}
@@ -0,0 +1,19 @@
1
+ type $$ComponentProps = {
2
+ /** What the head says. */
3
+ label: string;
4
+ /** The column's whole name, for the sort button's label. */
5
+ name: string;
6
+ align?: 'start' | 'end';
7
+ sortable: boolean;
8
+ /** This column's sort, when it is the one in force. */
9
+ sort: 'asc' | 'desc' | null;
10
+ /** The direction the first click sorts in. */
11
+ first?: 'asc' | 'desc';
12
+ onsort: () => void;
13
+ /** The `data-slot` prefix: the list that owns the head. */
14
+ owner: string;
15
+ class?: string;
16
+ };
17
+ declare const HeadCell: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type HeadCell = ReturnType<typeof HeadCell>;
19
+ export default HeadCell;
@@ -1,3 +1,3 @@
1
1
  export { default as Ledger } from './ledger.svelte';
2
2
  export { default } from './ledger.svelte';
3
- export type { LedgerColumn, LedgerOpenContext, LedgerOrigin, LedgerPeriod, LedgerPreferences, LedgerRow } from './types.js';
3
+ export type { LedgerColumn, LedgerOpenContext, LedgerOrigin, LedgerPeriod, LedgerPreferences, LedgerRow, ListColumn } from './types.js';