@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.
- package/README.md +100 -4
- package/dist/components/ui/context-column/context-column.svelte +118 -21
- package/dist/components/ui/context-column/context-column.svelte.d.ts +24 -1
- package/dist/components/ui/document-pane/document-pane.svelte +136 -0
- package/dist/components/ui/document-pane/document-pane.svelte.d.ts +43 -0
- package/dist/components/ui/document-pane/index.d.ts +2 -0
- package/dist/components/ui/document-pane/index.js +2 -0
- package/dist/components/ui/ledger/head-cell.svelte +73 -0
- package/dist/components/ui/ledger/head-cell.svelte.d.ts +19 -0
- package/dist/components/ui/ledger/index.d.ts +1 -1
- package/dist/components/ui/ledger/ledger-columns.svelte +29 -21
- package/dist/components/ui/ledger/ledger-columns.svelte.d.ts +50 -13
- package/dist/components/ui/ledger/ledger.d.ts +6 -0
- package/dist/components/ui/ledger/ledger.js +9 -1
- package/dist/components/ui/ledger/ledger.svelte +58 -78
- package/dist/components/ui/ledger/ledger.svelte.d.ts +6 -1
- package/dist/components/ui/ledger/look.d.ts +32 -0
- package/dist/components/ui/ledger/look.js +32 -0
- package/dist/components/ui/ledger/types.d.ts +21 -11
- package/dist/components/ui/list-toolbar/icon-action.svelte +42 -0
- package/dist/components/ui/list-toolbar/icon-action.svelte.d.ts +7 -0
- package/dist/components/ui/list-toolbar/index.d.ts +3 -0
- package/dist/components/ui/list-toolbar/index.js +2 -0
- package/dist/components/ui/list-toolbar/list-toolbar.svelte +85 -0
- package/dist/components/ui/list-toolbar/list-toolbar.svelte.d.ts +32 -0
- package/dist/components/ui/list-toolbar/types.d.ts +18 -0
- package/dist/components/ui/list-toolbar/types.js +1 -0
- package/dist/components/ui/panel/panel.svelte +8 -1
- package/dist/components/ui/panel/panel.svelte.d.ts +6 -0
- package/dist/components/ui/record-list/index.d.ts +3 -0
- package/dist/components/ui/record-list/index.js +2 -0
- package/dist/components/ui/record-list/record-list.svelte +489 -0
- package/dist/components/ui/record-list/record-list.svelte.d.ts +89 -0
- package/dist/components/ui/record-list/thumbnail.svelte +32 -0
- package/dist/components/ui/record-list/thumbnail.svelte.d.ts +14 -0
- package/dist/components/ui/record-list/types.d.ts +40 -0
- package/dist/components/ui/record-list/types.js +1 -0
- package/dist/components/ui/record-switcher/index.d.ts +3 -0
- package/dist/components/ui/record-switcher/index.js +2 -0
- package/dist/components/ui/record-switcher/record-switcher.svelte +167 -0
- package/dist/components/ui/record-switcher/record-switcher.svelte.d.ts +28 -0
- package/dist/components/ui/record-switcher/types.d.ts +22 -0
- package/dist/components/ui/record-switcher/types.js +1 -0
- package/package.json +1 -1
- package/registry/component-map.json +34 -6
- 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.
|
|
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
|
|
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
|
|
11
|
-
// one
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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=
|
|
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={
|
|
81
|
-
<div class=
|
|
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
|
|
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,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';
|