@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
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The bar above a list, on the page ground: the list's title and count,
4
+ * then its icon actions (each named by its tooltip) and its one labelled
5
+ * action. The ledger draws its own with this, so every list's bar is the
6
+ * same bar.
7
+ *
8
+ * <ListToolbar title="Owned properties" meta="3 properties"
9
+ * tools={[{ label: 'Download as CSV', icon: Download, onclick: save }]}
10
+ * action={{ label: 'Add property', icon: Plus, href: '/property/new' }} />
11
+ */
12
+ import type { Snippet } from 'svelte';
13
+ import type { HTMLAttributes } from 'svelte/elements';
14
+ import Button from '../button/button.svelte';
15
+ import * as Tooltip from '../tooltip/index.js';
16
+ import IconAction from './icon-action.svelte';
17
+ import { cn } from '../../../utils.js';
18
+ import type { ListAction, ListIconAction } from './types.js';
19
+
20
+ let {
21
+ title,
22
+ meta,
23
+ leading,
24
+ children,
25
+ tools = [],
26
+ action,
27
+ label,
28
+ class: className,
29
+ ...restProps
30
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
31
+ title?: string;
32
+ /** Beside the title, quieter: the count, or what the list is of. */
33
+ meta?: string;
34
+ /** Before the title: a switch between views of the same list. */
35
+ leading?: Snippet;
36
+ /** The list's own controls, before its icon actions: a menu, a filter. */
37
+ children?: Snippet;
38
+ /** Icon actions, each named by its tooltip. */
39
+ tools?: ListIconAction[];
40
+ /** The one labelled action, last. */
41
+ action?: ListAction;
42
+ /** The toolbar's accessible name. Defaults to the title. */
43
+ label?: string;
44
+ class?: string;
45
+ } = $props();
46
+
47
+ const ActionIcon = $derived(action?.icon);
48
+ </script>
49
+
50
+ <div
51
+ role="toolbar"
52
+ aria-label={label ?? title}
53
+ class={cn('flex min-h-11 flex-none items-center gap-2 px-2 pb-2', className)}
54
+ data-slot="list-toolbar"
55
+ {...restProps}
56
+ >
57
+ {@render leading?.()}
58
+ <!-- Crowded, the count gives way before the title, and the title ends in an ellipsis. -->
59
+ <div class="flex min-w-0 items-baseline gap-2.5 whitespace-nowrap">
60
+ {#if title}<h2 class="text-body min-w-0 truncate font-semibold">{title}</h2>{/if}
61
+ {#if meta}<span class="text-muted-foreground min-w-0 shrink-[100] truncate text-sm">{meta}</span>{/if}
62
+ </div>
63
+ <span class="min-w-0 flex-1"></span>
64
+ {@render children?.()}
65
+ {#if tools.length}
66
+ <Tooltip.Provider delayDuration={150}>
67
+ {#each tools as tool (tool.label)}
68
+ <IconAction action={tool} />
69
+ {/each}
70
+ </Tooltip.Provider>
71
+ {/if}
72
+ {#if action}
73
+ <Button
74
+ variant="outline"
75
+ size="sm"
76
+ href={action.href}
77
+ download={action.href ? (action.download === true ? '' : action.download || undefined) : undefined}
78
+ disabled={action.disabled}
79
+ onclick={action.onclick}
80
+ data-slot="list-toolbar-action"
81
+ >
82
+ {#if ActionIcon}<ActionIcon />{/if}{action.label}
83
+ </Button>
84
+ {/if}
85
+ </div>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The bar above a list, on the page ground: the list's title and count,
3
+ * then its icon actions (each named by its tooltip) and its one labelled
4
+ * action. The ledger draws its own with this, so every list's bar is the
5
+ * same bar.
6
+ *
7
+ * <ListToolbar title="Owned properties" meta="3 properties"
8
+ * tools={[{ label: 'Download as CSV', icon: Download, onclick: save }]}
9
+ * action={{ label: 'Add property', icon: Plus, href: '/property/new' }} />
10
+ */
11
+ import type { Snippet } from 'svelte';
12
+ import type { HTMLAttributes } from 'svelte/elements';
13
+ import type { ListAction, ListIconAction } from './types.js';
14
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
15
+ title?: string;
16
+ /** Beside the title, quieter: the count, or what the list is of. */
17
+ meta?: string;
18
+ /** Before the title: a switch between views of the same list. */
19
+ leading?: Snippet;
20
+ /** The list's own controls, before its icon actions: a menu, a filter. */
21
+ children?: Snippet;
22
+ /** Icon actions, each named by its tooltip. */
23
+ tools?: ListIconAction[];
24
+ /** The one labelled action, last. */
25
+ action?: ListAction;
26
+ /** The toolbar's accessible name. Defaults to the title. */
27
+ label?: string;
28
+ class?: string;
29
+ };
30
+ declare const ListToolbar: import("svelte").Component<$$ComponentProps, {}, "">;
31
+ type ListToolbar = ReturnType<typeof ListToolbar>;
32
+ export default ListToolbar;
@@ -0,0 +1,18 @@
1
+ import type { IconComponent } from '../app-shell/types.js';
2
+ /**
3
+ * One action a list or a pane offers: a button, or a link when it has `href`.
4
+ * As an icon action its `label` is the tooltip and the accessible name.
5
+ */
6
+ export interface ListAction {
7
+ label: string;
8
+ icon?: IconComponent;
9
+ onclick?: (event: MouseEvent) => void;
10
+ href?: string;
11
+ /** On a link, saves the target rather than opening it; a string names the file. */
12
+ download?: boolean | string;
13
+ disabled?: boolean;
14
+ }
15
+ /** An action drawn as its icon alone. */
16
+ export type ListIconAction = ListAction & {
17
+ icon: IconComponent;
18
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -25,6 +25,7 @@
25
25
  tone = 'default',
26
26
  children,
27
27
  pad = true,
28
+ scroll = false,
28
29
  class: klass = '',
29
30
  ref = $bindable(null),
30
31
  ...restProps
@@ -70,6 +71,12 @@
70
71
  tone?: 'default' | 'destructive';
71
72
  children: Snippet;
72
73
  pad?: boolean;
74
+ /**
75
+ * The body scrolls inside the card, under a header that stays: the card
76
+ * is as tall as what it holds up to the height its container gives it,
77
+ * and the full height when given one (`h-full`).
78
+ */
79
+ scroll?: boolean;
73
80
  } = $props();
74
81
 
75
82
  const destructive = $derived(tone === 'destructive');
@@ -124,7 +131,7 @@
124
131
  {/if}
125
132
  </header>
126
133
  {/if}
127
- <div class={pad ? 'p-4' : ''}>
134
+ <div class={cn(pad && 'p-4', scroll && 'min-h-0 flex-1 overflow-y-auto')} data-slot="panel-body">
128
135
  {@render children()}
129
136
  </div>
130
137
  {#if footer}
@@ -45,6 +45,12 @@ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
45
45
  tone?: 'default' | 'destructive';
46
46
  children: Snippet;
47
47
  pad?: boolean;
48
+ /**
49
+ * The body scrolls inside the card, under a header that stays: the card
50
+ * is as tall as what it holds up to the height its container gives it,
51
+ * and the full height when given one (`h-full`).
52
+ */
53
+ scroll?: boolean;
48
54
  };
49
55
  declare const Panel: Component<$$ComponentProps, {}, "ref">;
50
56
  type Panel = ReturnType<typeof Panel>;
@@ -0,0 +1,3 @@
1
+ export { default as RecordList } from './record-list.svelte';
2
+ export { default } from './record-list.svelte';
3
+ export type { RecordFlag, RecordListColumn, RecordListPreferences, RecordListRow } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as RecordList } from './record-list.svelte';
2
+ export { default } from './record-list.svelte';
@@ -0,0 +1,489 @@
1
+ <script lang="ts" generics="R extends RecordListRow">
2
+ /**
3
+ * The ledger's look for a list that is not money: properties, managers,
4
+ * tools, the lines of a profit and loss. The same bar, head card, row cards
5
+ * and group labels on the ground as the Ledger, so every list in an app
6
+ * reads one way. The consumer brings its rows and the columns it offers; the
7
+ * list owns the look, the grouping, the sort and the viewer's columns,
8
+ * which it emits for the consumer to keep.
9
+ *
10
+ * A row is its thumbnail, its title and a second line of flags and a note,
11
+ * then the module's columns. It opens by its link (`href`) or by `onOpen`,
12
+ * which marks the row `open` names, for a detail beside the list.
13
+ *
14
+ * It fills its parent's height and scrolls its own rows, so the head card
15
+ * stays at the top: give it a bounded parent (`flex-1 min-h-0`). Width picks
16
+ * the layout, as the Ledger's does: a phone's list below 600px, the wide
17
+ * tracks and the `'wide'` columns from 1200px. On a phone a row is its
18
+ * thumbnail, title and lines, with the first end-aligned column's value
19
+ * beside the title.
20
+ */
21
+ import type { Snippet } from 'svelte';
22
+ import type { HTMLAttributes } from 'svelte/elements';
23
+ import EmptyState from '../empty-state/empty-state.svelte';
24
+ import StatusBadge from '../status-badge/status-badge.svelte';
25
+ import HeadCell from '../ledger/head-cell.svelte';
26
+ import LedgerColumns from '../ledger/ledger-columns.svelte';
27
+ import { orderBy } from '../ledger/ledger.js';
28
+ import {
29
+ CARD,
30
+ CELL,
31
+ END,
32
+ GROUP,
33
+ GROUP_LABEL,
34
+ HEAD,
35
+ HEAD_GROUND,
36
+ RING_INSET,
37
+ ROW_HEIGHT,
38
+ ROW_TEXT,
39
+ RULE,
40
+ THROUGH,
41
+ groupSpacing
42
+ } from '../ledger/look.js';
43
+ import ListToolbar from '../list-toolbar/list-toolbar.svelte';
44
+ import type { ListAction, ListIconAction } from '../list-toolbar/types.js';
45
+ import type { IconComponent } from '../app-shell/types.js';
46
+ import Thumbnail from './thumbnail.svelte';
47
+ import { cn } from '../../../utils.js';
48
+ import type { RecordListColumn, RecordListPreferences, RecordListRow } from './types.js';
49
+
50
+ type Id = R['id'];
51
+
52
+ let {
53
+ rows,
54
+ columns = [],
55
+ preferences = $bindable(),
56
+ onPreferencesChange,
57
+ preferencesNote,
58
+ noun = ['record', 'records'],
59
+ title,
60
+ meta,
61
+ leading,
62
+ toolbar,
63
+ tools = [],
64
+ action,
65
+ group,
66
+ totalLabel,
67
+ head = true,
68
+ icon,
69
+ href,
70
+ onOpen,
71
+ open = null,
72
+ empty,
73
+ footer,
74
+ layout = 'auto',
75
+ class: className,
76
+ ...restProps
77
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
78
+ rows: readonly R[];
79
+ /** The module's columns, in order after the title. See `RecordListColumn`. */
80
+ columns?: RecordListColumn<R>[];
81
+ /** The viewer's columns. Missing, the module's defaults. */
82
+ preferences?: Partial<RecordListPreferences>;
83
+ onPreferencesChange?: (next: RecordListPreferences) => void;
84
+ /** The Columns menu's footnote: where the choice is kept. */
85
+ preferencesNote?: string;
86
+ /** What a row is, one and many: "property", "properties". */
87
+ noun?: [string, string];
88
+ title?: string;
89
+ /** Beside the title. Defaults to the row count. */
90
+ meta?: string;
91
+ /** Before the title: a switch between views of the same list. */
92
+ leading?: Snippet;
93
+ /** The bar's own controls, after the Columns menu. */
94
+ toolbar?: Snippet;
95
+ /** The bar's icon actions, each named by its tooltip. */
96
+ tools?: ListIconAction[];
97
+ /** The bar's one labelled action, last. */
98
+ action?: ListAction;
99
+ /** A row's group, by its label: groups show in the order their first rows come. */
100
+ group?: (row: R) => string;
101
+ /** Names a card after the rows with each column's total over every row: "All three". */
102
+ totalLabel?: string;
103
+ /** The head card. A list whose columns need no heads drops it. */
104
+ head?: boolean;
105
+ /** The tile a row without a photo or its own icon shows. With it every row has a thumbnail. */
106
+ icon?: IconComponent;
107
+ /** Where a row leads. */
108
+ href?: (row: R) => string | undefined;
109
+ /** A row's click, for a detail beside the list. */
110
+ onOpen?: (row: R) => void;
111
+ /** The row whose detail is open, marked. */
112
+ open?: Id | null;
113
+ empty?: Snippet;
114
+ /** After the last row, inside the scroll. */
115
+ footer?: Snippet;
116
+ layout?: 'auto' | 'phone' | 'narrow' | 'wide';
117
+ class?: string;
118
+ } = $props();
119
+
120
+ const TITLE: Omit<RecordListColumn<R>, 'key'> = {
121
+ label: 'Name',
122
+ width: { narrow: 'minmax(0, 1fr)', wide: 'minmax(16rem, 1.5fr)' },
123
+ sort: (r) => r.title
124
+ };
125
+ const TRACK = { narrow: '6rem', wide: 'minmax(8rem, 1fr)' };
126
+ /** The first cell's inset: the card's edge to the thumbnail or the title. */
127
+ const FIRST = 'pl-6';
128
+
129
+ // ── Columns: the title, then the module's ──
130
+ const resolved = $derived.by(() => {
131
+ const own = columns.find((c) => c.key === 'title');
132
+ return [
133
+ { ...TITLE, ...own, key: 'title', locked: true },
134
+ ...columns.filter((c) => c.key !== 'title').map((c) => ({ ...c, locked: false }))
135
+ ].map((c) => ({
136
+ ...c,
137
+ on: c.on ?? true,
138
+ track: typeof c.width === 'string' ? { narrow: c.width, wide: c.width } : (c.width ?? TRACK)
139
+ }));
140
+ });
141
+
142
+ // ── Layout ──
143
+ let width = $state(0);
144
+ let listOuter = $state(0);
145
+ let listInner = $state(0);
146
+ /** The bar ends where the cards do: clear of the scroll bar's gutter. */
147
+ const edge = $derived(`calc(0.5rem + ${Math.max(0, listOuter - listInner)}px)`);
148
+ const mode = $derived(
149
+ layout !== 'auto'
150
+ ? layout
151
+ : width === 0
152
+ ? 'narrow'
153
+ : width < 600
154
+ ? 'phone'
155
+ : width >= 1200
156
+ ? 'wide'
157
+ : 'narrow'
158
+ );
159
+ const WIDE = $derived(mode === 'wide');
160
+ const PHONE = $derived(mode === 'phone');
161
+
162
+ const defaults = $derived<RecordListPreferences>({
163
+ columns: resolved
164
+ .filter((c) => !c.locked && (c.on === true || (c.on === 'wide' && WIDE)))
165
+ .map((c) => c.key)
166
+ });
167
+ const prefs = $derived<RecordListPreferences>({
168
+ columns: preferences?.columns ?? defaults.columns
169
+ });
170
+ function setPreferences(next: RecordListPreferences) {
171
+ preferences = next;
172
+ onPreferencesChange?.(next);
173
+ }
174
+ /** The Columns menu, in the bar, when the heads name a column the viewer can choose. */
175
+ const choosable = $derived(head && resolved.some((c) => !c.locked));
176
+
177
+ const shown = $derived(resolved.filter((c) => c.locked || prefs.columns.includes(c.key)));
178
+ const template = $derived(shown.map((c) => (WIDE ? c.track.wide : c.track.narrow)).join(' '));
179
+ /** On a phone, the value beside the title. */
180
+ const phoneColumn = $derived(shown.find((c) => !c.locked && c.align === 'end'));
181
+
182
+ // ── Sorting: the viewer's own, kept here until a head is clicked again ──
183
+ let sort = $state<{ key: string; dir: 1 | -1 } | null>(null);
184
+ const sorter = (c: { sort?: RecordListColumn<R>['sort']; text?: RecordListColumn<R>['text'] }) =>
185
+ c.sort ?? c.text;
186
+ /**
187
+ * Click a head to sort by it, largest first and words from A; again to
188
+ * reverse; a third time back to the list's order.
189
+ */
190
+ function firstOf(c: (typeof resolved)[number]): 1 | -1 {
191
+ const by = sorter(c);
192
+ return by && rows.some((r) => typeof by(r) === 'string') ? 1 : -1;
193
+ }
194
+ function sortBy(key: string) {
195
+ const first = firstOf(resolved.find((c) => c.key === key)!);
196
+ sort =
197
+ sort?.key !== key
198
+ ? { key, dir: first }
199
+ : sort.dir === first
200
+ ? { key, dir: first === 1 ? -1 : 1 }
201
+ : null;
202
+ }
203
+ /** The sort in force: a column the viewer has since unticked no longer sorts. */
204
+ const activeSort = $derived.by(() => {
205
+ const col = sort && shown.find((c) => c.key === sort!.key);
206
+ return sort && col && sorter(col) ? sort : null;
207
+ });
208
+
209
+ // ── Groups, in the order their first rows come; a sort orders rows inside them ──
210
+ const groups = $derived.by(() => {
211
+ const out: { key: string; rows: R[] }[] = [];
212
+ for (const r of rows) {
213
+ const key = group ? group(r) : '';
214
+ const g = out.find((x) => x.key === key);
215
+ if (g) g.rows.push(r);
216
+ else out.push({ key, rows: [r] });
217
+ }
218
+ const col = activeSort && shown.find((c) => c.key === activeSort.key);
219
+ const by = col && sorter(col);
220
+ return by ? out.map((g) => ({ key: g.key, rows: orderBy(g.rows, by, activeSort!.dir) })) : out;
221
+ });
222
+
223
+ const thumbs = $derived(!!icon || rows.some((r) => r.image || r.icon));
224
+ const openable = $derived(!!href || !!onOpen);
225
+ const totals = $derived(!!totalLabel && shown.some((c) => c.total));
226
+ const count = (n: number) => `${n.toLocaleString()} ${n === 1 ? noun[0] : noun[1]}`;
227
+ </script>
228
+
229
+ {#snippet value(c: (typeof shown)[number], r: R)}
230
+ {#if c.cell}{@render c.cell(r)}{:else}{c.text?.(r) ?? ''}{/if}
231
+ {/snippet}
232
+
233
+ {#snippet flags(r: R, all: boolean)}
234
+ {@const fs = r.flags ?? []}
235
+ {#each all ? fs : fs.slice(0, 1) as f, i (i)}
236
+ <StatusBadge status={f.status} label={f.label} class="flex-none" />
237
+ {/each}
238
+ {#if !all && fs.length > 1}
239
+ <span
240
+ class="pointer-events-auto flex-none whitespace-nowrap"
241
+ title={fs
242
+ .slice(1)
243
+ .map((f) => f.label)
244
+ .join('\n')}
245
+ data-slot="record-more"
246
+ >+{fs.length - 1} more<span class="sr-only"
247
+ >: {fs
248
+ .slice(1)
249
+ .map((f) => f.label)
250
+ .join(', ')}</span
251
+ ></span
252
+ >
253
+ {/if}
254
+ {/snippet}
255
+
256
+ {#snippet titleCell(r: R)}
257
+ {#if thumbs}<Thumbnail src={r.image} icon={r.icon ?? icon} class="size-12" />{/if}
258
+ <span class="flex min-w-0 flex-1 flex-col justify-center gap-1">
259
+ <span class="truncate font-medium" data-slot="record-title">{r.title}</span>
260
+ {#if r.flags?.length || r.note}
261
+ <span class="text-muted-foreground flex min-w-0 items-center gap-2 overflow-hidden text-xs">
262
+ {@render flags(r, false)}
263
+ {#if r.note}<span class="min-w-0 truncate">{r.note}</span>{/if}
264
+ </span>
265
+ {/if}
266
+ </span>
267
+ {/snippet}
268
+
269
+ {#snippet opener(r: R, klass: string, style?: string)}
270
+ {@const to = href?.(r)}
271
+ {@const current = open !== null && r.id === open}
272
+ {#if to}
273
+ <a
274
+ href={to}
275
+ class={cn('cursor-pointer', RING_INSET, klass)}
276
+ {style}
277
+ aria-label={r.title}
278
+ aria-current={current ? 'true' : undefined}
279
+ onclick={() => onOpen?.(r)}
280
+ data-slot="record-open"
281
+ ></a>
282
+ {:else if onOpen}
283
+ <button
284
+ type="button"
285
+ class={cn('cursor-pointer', RING_INSET, klass)}
286
+ {style}
287
+ aria-label="Open {r.title}"
288
+ aria-current={current ? 'true' : undefined}
289
+ onclick={() => onOpen(r)}
290
+ data-slot="record-open"
291
+ ></button>
292
+ {/if}
293
+ {/snippet}
294
+
295
+ {#snippet row(r: R, first: boolean)}
296
+ {@const current = open !== null && r.id === open}
297
+ {@const through = openable && THROUGH}
298
+ {@const mark = cn(
299
+ !first && RULE,
300
+ current &&
301
+ 'bg-accent/60 before:bg-primary before:absolute before:inset-y-0 before:left-0 before:w-0.5',
302
+ openable && !current && 'hover:bg-accent/40'
303
+ )}
304
+ {#if PHONE}
305
+ <div class={cn('relative flex gap-3 px-3 py-2.5 text-sm', mark)} data-slot="record-row">
306
+ {@render opener(r, 'absolute inset-0')}
307
+ {#if thumbs}<Thumbnail src={r.image} icon={r.icon ?? icon} class="size-12" />{/if}
308
+ <span class={cn('relative flex min-w-0 flex-1 flex-col gap-1', through)}>
309
+ <span class="flex min-w-0 items-baseline gap-3">
310
+ <span class="min-w-0 flex-1 truncate font-medium" data-slot="record-title">{r.title}</span>
311
+ {#if phoneColumn}
312
+ <span class="flex-none tabular-nums">{@render value(phoneColumn, r)}</span>
313
+ {/if}
314
+ </span>
315
+ {#if r.note}<span class="text-muted-foreground text-xs">{r.note}</span>{/if}
316
+ {#if r.flags?.length}
317
+ <span class="flex flex-wrap items-center gap-1.5">{@render flags(r, true)}</span>
318
+ {/if}
319
+ </span>
320
+ </div>
321
+ {:else}
322
+ <div
323
+ class={cn('relative grid items-stretch', ROW_TEXT, mark)}
324
+ style="grid-template-columns: {template}; {ROW_HEIGHT}"
325
+ data-slot="record-row"
326
+ >
327
+ {@render opener(r, '', 'grid-column: 1 / -1; grid-row: 1;')}
328
+ {#each shown as c, i (c.key)}
329
+ {@const style = `grid-column: ${i + 1}; grid-row: 1;`}
330
+ {#if c.key === 'title' && !c.cell}
331
+ <span class={cn('flex min-w-0 items-center gap-3 py-2.5 pr-2', FIRST, through)} {style}>
332
+ {@render titleCell(r)}
333
+ </span>
334
+ {:else}
335
+ <!-- A text cell stays a block so it ends in an ellipsis; a cell of the module's markup centres it. -->
336
+ <span
337
+ class={cn(
338
+ CELL,
339
+ i === 0 && FIRST,
340
+ through,
341
+ 'self-center',
342
+ c.align === 'end' && cn(END, 'tabular-nums'),
343
+ c.cell && 'flex items-center',
344
+ c.cell && c.align === 'end' && 'justify-end'
345
+ )}
346
+ {style}
347
+ >
348
+ {@render value(c, r)}
349
+ </span>
350
+ {/if}
351
+ {/each}
352
+ </div>
353
+ {/if}
354
+ {/snippet}
355
+
356
+ {#snippet total()}
357
+ {#if PHONE}
358
+ <span class="min-w-0 flex-1 truncate">{totalLabel}</span>
359
+ {#if phoneColumn?.total}
360
+ <span class="tabular-nums">{phoneColumn.total(rows)}</span>
361
+ {/if}
362
+ {:else}
363
+ {#each shown as c, i (c.key)}
364
+ {#if i === 0}
365
+ <span class={cn(CELL, FIRST)} style="grid-column: 1;">{totalLabel}</span>
366
+ {:else if c.total}
367
+ <span class={cn(CELL, c.align === 'end' && END, 'tabular-nums')} style="grid-column: {i + 1};">
368
+ {c.total(rows)}
369
+ </span>
370
+ {/if}
371
+ {/each}
372
+ {/if}
373
+ {/snippet}
374
+
375
+ <div
376
+ bind:clientWidth={width}
377
+ class={cn('relative flex min-h-0 flex-1 flex-col', className)}
378
+ data-slot="record-list"
379
+ data-layout={mode}
380
+ {...restProps}
381
+ >
382
+ {#if title || meta || leading || toolbar || tools.length || action}
383
+ <ListToolbar
384
+ {title}
385
+ meta={meta ?? count(rows.length)}
386
+ {leading}
387
+ {tools}
388
+ {action}
389
+ style="padding-right: {edge};"
390
+ >
391
+ {#if choosable}
392
+ <LedgerColumns
393
+ offered={resolved.map((c) => ({ key: c.key, label: c.label, locked: c.locked }))}
394
+ preferences={prefs}
395
+ note={preferencesNote}
396
+ onchange={setPreferences}
397
+ onreset={() => setPreferences(defaults)}
398
+ owner="record"
399
+ />
400
+ {/if}
401
+ {@render toolbar?.()}
402
+ </ListToolbar>
403
+ {/if}
404
+
405
+ <div
406
+ bind:offsetWidth={listOuter}
407
+ bind:clientWidth={listInner}
408
+ class="relative min-h-0 flex-1 overflow-auto px-2 pb-3 [scrollbar-width:thin]"
409
+ style="scrollbar-gutter: stable;"
410
+ data-slot="record-rows"
411
+ >
412
+ {#if head && !PHONE}
413
+ <div class={HEAD_GROUND}>
414
+ <div class={HEAD} style:grid-template-columns={template} data-slot="record-head">
415
+ {#each shown as c, i (c.key)}
416
+ {@const mine = activeSort?.key === c.key ? activeSort : null}
417
+ <HeadCell
418
+ label={c.head ?? c.label}
419
+ name={c.label}
420
+ align={c.align}
421
+ sortable={!!sorter(c)}
422
+ sort={mine ? (mine.dir === -1 ? 'desc' : 'asc') : null}
423
+ first={firstOf(c) === 1 ? 'asc' : 'desc'}
424
+ onsort={() => sortBy(c.key)}
425
+ owner="record"
426
+ class={i === 0 ? FIRST : undefined}
427
+ />
428
+ {/each}
429
+ </div>
430
+ </div>
431
+ {/if}
432
+
433
+ {#if rows.length === 0}
434
+ {#if empty}{@render empty()}{:else}<EmptyState title="No {noun[1]}" />{/if}
435
+ {:else}
436
+ {#each groups as g, gi (g.key)}
437
+ {#if group}
438
+ <div class={groupSpacing(gi)}>
439
+ <div
440
+ class={cn(GROUP, PHONE ? 'flex items-center gap-2 px-0.5' : 'grid items-center')}
441
+ style:grid-template-columns={PHONE ? undefined : template}
442
+ data-slot="record-group"
443
+ >
444
+ {#if PHONE}
445
+ <h3 class={cn(GROUP_LABEL, 'whitespace-nowrap')}>{g.key}</h3>
446
+ <span class="min-w-0 flex-1 truncate">{g.rows.length.toLocaleString()}</span>
447
+ {:else}
448
+ <div
449
+ class="flex min-w-0 items-baseline gap-3 overflow-hidden pl-3 whitespace-nowrap"
450
+ style="grid-column: 1;"
451
+ >
452
+ <h3 class={GROUP_LABEL}>{g.key}</h3>
453
+ <span class="min-w-0 truncate">{count(g.rows.length)}</span>
454
+ </div>
455
+ {#each shown as c, i (c.key)}
456
+ {#if i > 0 && c.total}
457
+ <span
458
+ class={cn(CELL, c.align === 'end' && END, 'tabular-nums')}
459
+ style="grid-column: {i + 1};">{c.total(g.rows)}</span
460
+ >
461
+ {/if}
462
+ {/each}
463
+ {/if}
464
+ </div>
465
+ </div>
466
+ {/if}
467
+ <div class={CARD} data-slot="record-card">
468
+ {#each g.rows as r, ri (r.id)}
469
+ {@render row(r, ri === 0)}
470
+ {/each}
471
+ </div>
472
+ {/each}
473
+ {#if totals}
474
+ <div class={cn(CARD, 'mt-3')} data-slot="record-total">
475
+ <div
476
+ class={cn(
477
+ 'font-semibold',
478
+ PHONE ? 'flex items-center gap-3 px-3 py-3 text-sm' : cn('grid items-center', ROW_TEXT)
479
+ )}
480
+ style={PHONE ? undefined : `grid-template-columns: ${template}; ${ROW_HEIGHT}`}
481
+ >
482
+ {@render total()}
483
+ </div>
484
+ </div>
485
+ {/if}
486
+ {/if}
487
+ {@render footer?.()}
488
+ </div>
489
+ </div>