@poodle64/ui 2026.10.3 → 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 +105 -7
  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 +65 -84
  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,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>
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The ledger's look for a list that is not money: properties, managers,
3
+ * tools, the lines of a profit and loss. The same bar, head card, row cards
4
+ * and group labels on the ground as the Ledger, so every list in an app
5
+ * reads one way. The consumer brings its rows and the columns it offers; the
6
+ * list owns the look, the grouping, the sort and the viewer's columns,
7
+ * which it emits for the consumer to keep.
8
+ *
9
+ * A row is its thumbnail, its title and a second line of flags and a note,
10
+ * then the module's columns. It opens by its link (`href`) or by `onOpen`,
11
+ * which marks the row `open` names, for a detail beside the list.
12
+ *
13
+ * It fills its parent's height and scrolls its own rows, so the head card
14
+ * stays at the top: give it a bounded parent (`flex-1 min-h-0`). Width picks
15
+ * the layout, as the Ledger's does: a phone's list below 600px, the wide
16
+ * tracks and the `'wide'` columns from 1200px. On a phone a row is its
17
+ * thumbnail, title and lines, with the first end-aligned column's value
18
+ * beside the title.
19
+ */
20
+ import type { Snippet } from 'svelte';
21
+ import type { HTMLAttributes } from 'svelte/elements';
22
+ import type { ListAction, ListIconAction } from '../list-toolbar/types.js';
23
+ import type { IconComponent } from '../app-shell/types.js';
24
+ import type { RecordListColumn, RecordListPreferences, RecordListRow } from './types.js';
25
+ declare function $$render<R extends RecordListRow>(): {
26
+ props: Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
27
+ rows: readonly R[];
28
+ /** The module's columns, in order after the title. See `RecordListColumn`. */
29
+ columns?: RecordListColumn<R>[];
30
+ /** The viewer's columns. Missing, the module's defaults. */
31
+ preferences?: Partial<RecordListPreferences>;
32
+ onPreferencesChange?: (next: RecordListPreferences) => void;
33
+ /** The Columns menu's footnote: where the choice is kept. */
34
+ preferencesNote?: string;
35
+ /** What a row is, one and many: "property", "properties". */
36
+ noun?: [string, string];
37
+ title?: string;
38
+ /** Beside the title. Defaults to the row count. */
39
+ meta?: string;
40
+ /** Before the title: a switch between views of the same list. */
41
+ leading?: Snippet;
42
+ /** The bar's own controls, after the Columns menu. */
43
+ toolbar?: Snippet;
44
+ /** The bar's icon actions, each named by its tooltip. */
45
+ tools?: ListIconAction[];
46
+ /** The bar's one labelled action, last. */
47
+ action?: ListAction;
48
+ /** A row's group, by its label: groups show in the order their first rows come. */
49
+ group?: (row: R) => string;
50
+ /** Names a card after the rows with each column's total over every row: "All three". */
51
+ totalLabel?: string;
52
+ /** The head card. A list whose columns need no heads drops it. */
53
+ head?: boolean;
54
+ /** The tile a row without a photo or its own icon shows. With it every row has a thumbnail. */
55
+ icon?: IconComponent;
56
+ /** Where a row leads. */
57
+ href?: (row: R) => string | undefined;
58
+ /** A row's click, for a detail beside the list. */
59
+ onOpen?: (row: R) => void;
60
+ /** The row whose detail is open, marked. */
61
+ open?: R["id"] | null;
62
+ empty?: Snippet;
63
+ /** After the last row, inside the scroll. */
64
+ footer?: Snippet;
65
+ layout?: "auto" | "phone" | "narrow" | "wide";
66
+ class?: string;
67
+ };
68
+ exports: {};
69
+ bindings: "preferences";
70
+ slots: {};
71
+ events: {};
72
+ };
73
+ declare class __sveltets_Render<R extends RecordListRow> {
74
+ props(): ReturnType<typeof $$render<R>>['props'];
75
+ events(): ReturnType<typeof $$render<R>>['events'];
76
+ slots(): ReturnType<typeof $$render<R>>['slots'];
77
+ bindings(): "preferences";
78
+ exports(): {};
79
+ }
80
+ interface $$IsomorphicComponent {
81
+ new <R extends RecordListRow>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<R>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<R>['props']>, ReturnType<__sveltets_Render<R>['events']>, ReturnType<__sveltets_Render<R>['slots']>> & {
82
+ $$bindings?: ReturnType<__sveltets_Render<R>['bindings']>;
83
+ } & ReturnType<__sveltets_Render<R>['exports']>;
84
+ <R extends RecordListRow>(internal: unknown, props: ReturnType<__sveltets_Render<R>['props']> & {}): ReturnType<__sveltets_Render<R>['exports']>;
85
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
86
+ }
87
+ declare const RecordList: $$IsomorphicComponent;
88
+ type RecordList<R extends RecordListRow> = InstanceType<typeof RecordList<R>>;
89
+ export default RecordList;
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A record's picture at a fixed size: its photo, or a quiet tile with the
4
+ * module's icon when it has none or the photo fails to load. Decorative:
5
+ * the record's name always sits beside it.
6
+ */
7
+ import type { IconComponent } from '../app-shell/types.js';
8
+ import { cn } from '../../../utils.js';
9
+
10
+ let {
11
+ src,
12
+ icon: Icon,
13
+ class: className
14
+ }: { src?: string | null; icon?: IconComponent; class?: string } = $props();
15
+
16
+ let failed = $state<string | null>(null);
17
+ </script>
18
+
19
+ <span
20
+ class={cn(
21
+ 'bg-surface-2 border-border/60 grid flex-none place-items-center overflow-hidden rounded-md border',
22
+ className
23
+ )}
24
+ aria-hidden="true"
25
+ data-slot="record-thumbnail"
26
+ >
27
+ {#if src && failed !== src}
28
+ <img {src} alt="" loading="lazy" class="size-full object-cover" onerror={() => (failed = src)} />
29
+ {:else if Icon}
30
+ <Icon class="text-primary size-1/2" />
31
+ {/if}
32
+ </span>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A record's picture at a fixed size: its photo, or a quiet tile with the
3
+ * module's icon when it has none or the photo fails to load. Decorative:
4
+ * the record's name always sits beside it.
5
+ */
6
+ import type { IconComponent } from '../app-shell/types.js';
7
+ type $$ComponentProps = {
8
+ src?: string | null;
9
+ icon?: IconComponent;
10
+ class?: string;
11
+ };
12
+ declare const Thumbnail: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type Thumbnail = ReturnType<typeof Thumbnail>;
14
+ export default Thumbnail;
@@ -0,0 +1,40 @@
1
+ import type { IconComponent } from '../app-shell/types.js';
2
+ import type { ListColumn } from '../ledger/types.js';
3
+ import type { Status } from '../status/index.js';
4
+ /** Something on file that wants the viewer: a chip on the row's second line. */
5
+ export interface RecordFlag {
6
+ status: Status;
7
+ label: string;
8
+ }
9
+ /** What the list reads off a row. A consumer extends it with its own fields for its columns. */
10
+ export interface RecordListRow {
11
+ id: string | number;
12
+ /** Line one: the record's name. */
13
+ title: string;
14
+ /** Line two, after any flags. */
15
+ note?: string | null;
16
+ /** A thumbnail before the title. Without one, `icon` or the list's `icon` stands in. */
17
+ image?: string | null;
18
+ /** The row's own tile, where each row is a kind of thing (a tool) rather than a photo. */
19
+ icon?: IconComponent;
20
+ /**
21
+ * What wants the viewer, first first. Line two shows the first as a chip
22
+ * and "+N more" naming the rest on hover; a phone's row shows every one.
23
+ */
24
+ flags?: readonly RecordFlag[] | null;
25
+ }
26
+ /**
27
+ * A column the module offers. The key `title` is the list's own cell (the
28
+ * thumbnail, the title, the flags and the note): list it to rename or resize
29
+ * it, or to draw it yourself with `cell`. It always shows, first. Every other
30
+ * key is the module's, in its order.
31
+ */
32
+ export interface RecordListColumn<R extends RecordListRow = RecordListRow> extends ListColumn<R> {
33
+ /** The column's total over some rows: each group's on its label, all rows' in the total card. */
34
+ total?: (rows: readonly R[]) => string;
35
+ }
36
+ /** The viewer's own choices, which the consumer persists. */
37
+ export interface RecordListPreferences {
38
+ /** The ticked columns' keys. */
39
+ columns: string[];
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { default as RecordSwitcher } from './record-switcher.svelte';
2
+ export { default } from './record-switcher.svelte';
3
+ export type { RecordPage, RecordSwitcherGroup, RecordSwitcherItem } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as RecordSwitcher } from './record-switcher.svelte';
2
+ export { default } from './record-switcher.svelte';