@poodle64/ui 2026.9.23 → 2026.10.1

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.
@@ -0,0 +1,905 @@
1
+ <script lang="ts" generics="R extends LedgerRow">
2
+ /**
3
+ * The household ledger: one component for every money list, read the same
4
+ * way in every module. The consumer brings its rows and the columns it
5
+ * offers; the ledger owns the look (PocketSmith's: a head card, one card per
6
+ * period, the group's label on the page ground), the grouping and the
7
+ * viewer's toggles, which it emits for the consumer to keep.
8
+ *
9
+ * It fills its parent's height and scrolls its own rows, so the head card
10
+ * stays at the top: give it a bounded parent (`flex-1 min-h-0` in a
11
+ * full-height page). Width picks the layout: a phone's list below 600px, the
12
+ * wide column widths from 1200px.
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ import type { HTMLAttributes } from 'svelte/elements';
16
+ import type { Attachment } from 'svelte/attachments';
17
+ import { SvelteSet } from 'svelte/reactivity';
18
+ import { DEV } from 'esm-env';
19
+ import ChevronDown from '@lucide/svelte/icons/chevron-down';
20
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
21
+ import Paperclip from '@lucide/svelte/icons/paperclip';
22
+ import X from '@lucide/svelte/icons/x';
23
+ import Button from '../button/button.svelte';
24
+ import { Checkbox } from '../checkbox/index.js';
25
+ import EmptyState from '../empty-state/empty-state.svelte';
26
+ import LedgerColumns from './ledger-columns.svelte';
27
+ import { AU_LOCALE } from '../../../format.js';
28
+ import { cn } from '../../../utils.js';
29
+ import {
30
+ LEDGER_PERIODS,
31
+ blocksOf,
32
+ cents,
33
+ fySpan,
34
+ groupRows,
35
+ ledgerDate,
36
+ ledgerItems,
37
+ ledgerMoney,
38
+ periodLabel,
39
+ windowOf
40
+ } from './ledger.js';
41
+ import type { LedgerColumn, LedgerOpenContext, LedgerPreferences, LedgerRow } from './types.js';
42
+
43
+ type Id = R['id'];
44
+
45
+ let {
46
+ rows,
47
+ columns = [],
48
+ preferences = $bindable(),
49
+ onPreferencesChange,
50
+ preferencesNote,
51
+ fyStart = 7,
52
+ currency = 'AUD',
53
+ locale = AU_LOCALE,
54
+ noun = ['transaction', 'transactions'],
55
+ title,
56
+ meta,
57
+ actions,
58
+ selected = $bindable([]),
59
+ bulkActions,
60
+ open = $bindable(null),
61
+ onOpenChange,
62
+ editor,
63
+ originActions,
64
+ onAttachment,
65
+ empty,
66
+ footer,
67
+ layout = 'auto',
68
+ class: className,
69
+ ...restProps
70
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
71
+ rows: readonly R[];
72
+ /** The module's columns, in order after the amount. See `LedgerColumn`. */
73
+ columns?: LedgerColumn<R>[];
74
+ /** The viewer's columns and period. Missing fields take the module's defaults. */
75
+ preferences?: Partial<LedgerPreferences>;
76
+ onPreferencesChange?: (next: LedgerPreferences) => void;
77
+ /** The Columns menu's footnote: where the choice is kept. */
78
+ preferencesNote?: string;
79
+ /** The month a financial year starts, 1–12. July by default. */
80
+ fyStart?: number;
81
+ currency?: string;
82
+ /** BCP 47 locale for every figure: amounts, nets and counts. */
83
+ locale?: string;
84
+ /** What a row is called, one and many: a printed layer's "line". */
85
+ noun?: [string, string];
86
+ title?: string;
87
+ /** Beside the title. Defaults to the row count. */
88
+ meta?: string;
89
+ /** The toolbar's own actions, after the Columns menu: export, add. */
90
+ actions?: Snippet;
91
+ /** The ticked rows' ids. Ticking needs `bulkActions`. */
92
+ selected?: Id[];
93
+ /** What the ticked rows allow, in the bar that replaces the toolbar. */
94
+ bulkActions?: Snippet<[R[]]>;
95
+ /** The row opened in place. Opening needs `editor`. */
96
+ open?: Id | null;
97
+ onOpenChange?: (id: Id | null) => void;
98
+ /** The opened row's content. */
99
+ editor?: Snippet<[R, LedgerOpenContext]>;
100
+ /** Under a synthetic row's statement lines: split, join, undo. */
101
+ originActions?: Snippet<[R]>;
102
+ /** Makes the paperclip a button. */
103
+ onAttachment?: (row: R) => void;
104
+ empty?: Snippet;
105
+ /** After the last group, inside the scroll: "Show more". */
106
+ footer?: Snippet;
107
+ layout?: 'auto' | 'phone' | 'narrow' | 'wide';
108
+ class?: string;
109
+ } = $props();
110
+
111
+ const BUILT_IN: Record<string, Omit<LedgerColumn<R>, 'key'>> = {
112
+ date: { label: 'Date', width: { narrow: '4.25rem', wide: '7rem' } },
113
+ title: {
114
+ label: 'Description',
115
+ width: { narrow: 'minmax(0, 1fr)', wide: 'minmax(16rem, 1.5fr)' }
116
+ },
117
+ amount: { label: 'Amount', width: { narrow: '7.25rem', wide: '9.5rem' }, align: 'end' },
118
+ balance: {
119
+ label: 'Running balance',
120
+ head: 'Balance',
121
+ width: { narrow: '7.5rem', wide: '10rem' },
122
+ align: 'end',
123
+ on: false
124
+ }
125
+ };
126
+ const GUTTER = '2.5rem';
127
+ const CELL = 'min-w-0 truncate px-2';
128
+ const END = 'pr-5 text-right';
129
+ /** The row's height rides the package's density ramp: 3.5rem, 3rem compact. */
130
+ const ROW_HEIGHT = 'min-height: calc(var(--ds-control-height-md) + 1rem);';
131
+ /** The house focus ring, and the same ring drawn inside a control that fills a clipped card. */
132
+ const RING = 'focus-visible:ring-ring/50 focus-visible:ring-3 focus-visible:outline-none';
133
+ const RING_INSET =
134
+ 'focus-visible:inset-ring-ring/50 focus-visible:inset-ring-3 focus-visible:outline-none';
135
+ /**
136
+ * A cell over an openable row lets a click through to the row beneath it, and
137
+ * gives one back to any control a module's cell renders, so a link or a button
138
+ * in a cell is never silently inert.
139
+ */
140
+ const THROUGH =
141
+ 'pointer-events-none [&_:is(a,button,input,select,textarea,label,summary,[role=button],[role=link],[role=checkbox],[tabindex])]:pointer-events-auto';
142
+
143
+ // ── Columns: the ledger's own three, the module's, the balance last ──
144
+ const resolved = $derived.by(() => {
145
+ const own = (key: string) => columns.find((c) => c.key === key);
146
+ const fixed = ['date', 'title', 'amount'].map((key) => ({
147
+ ...BUILT_IN[key],
148
+ ...own(key),
149
+ key,
150
+ locked: true
151
+ }));
152
+ const module = columns
153
+ .filter((c) => !(c.key in BUILT_IN))
154
+ .map((c) => ({ ...c, locked: false }));
155
+ const balance = own('balance')
156
+ ? [{ ...BUILT_IN.balance, ...own('balance'), key: 'balance', locked: false }]
157
+ : [];
158
+ return [...fixed, ...module, ...balance].map((c) => ({
159
+ ...c,
160
+ on: c.on ?? true,
161
+ track:
162
+ typeof c.width === 'string'
163
+ ? { narrow: c.width, wide: c.width }
164
+ : (c.width ?? { narrow: '6rem', wide: 'minmax(8rem, 1fr)' })
165
+ }));
166
+ });
167
+
168
+ const defaults = $derived<LedgerPreferences>({
169
+ columns: resolved.filter((c) => !c.locked && c.on).map((c) => c.key),
170
+ period: 'month'
171
+ });
172
+ /** What the consumer kept, read field by field: a stale or foreign key falls back. */
173
+ const prefs = $derived.by<LedgerPreferences>(() => {
174
+ const period = preferences?.period;
175
+ return {
176
+ columns: preferences?.columns ?? defaults.columns,
177
+ period: period && LEDGER_PERIODS.some((p) => p.value === period) ? period : defaults.period
178
+ };
179
+ });
180
+
181
+ function setPreferences(next: LedgerPreferences) {
182
+ preferences = next;
183
+ onPreferencesChange?.(next);
184
+ }
185
+
186
+ // ── Layout ──
187
+ let width = $state(0);
188
+ let listOuter = $state(0);
189
+ let listInner = $state(0);
190
+ /** The bars above the list end where its cards do: clear of its scroll bar's gutter. */
191
+ const edge = $derived(`calc(0.5rem + ${Math.max(0, listOuter - listInner)}px)`);
192
+ const mode = $derived(
193
+ layout !== 'auto'
194
+ ? layout
195
+ : width === 0
196
+ ? 'narrow'
197
+ : width < 600
198
+ ? 'phone'
199
+ : width >= 1200
200
+ ? 'wide'
201
+ : 'narrow'
202
+ );
203
+ const WIDE = $derived(mode === 'wide');
204
+ const PHONE = $derived(mode === 'phone');
205
+ const BY_DAY = $derived(prefs.period === 'day');
206
+
207
+ const shown = $derived(
208
+ resolved.filter(
209
+ (c) => (c.locked || prefs.columns.includes(c.key)) && !(c.key === 'date' && BY_DAY)
210
+ )
211
+ );
212
+ const template = $derived(
213
+ `${GUTTER} ${shown.map((c) => (WIDE ? c.track.wide : c.track.narrow)).join(' ')}`
214
+ );
215
+ /** A column's grid line, the gutter being 1; 0 when it is not shown. */
216
+ const column = (key: string) => {
217
+ const i = shown.findIndex((c) => c.key === key);
218
+ return i < 0 ? 0 : i + 2;
219
+ };
220
+ const cAmount = $derived(column('amount'));
221
+ const afterAmount = $derived(shown.length + 1 - cAmount);
222
+ const balanceColumn = $derived(resolved.find((c) => c.key === 'balance'));
223
+ const balanceOn = $derived(!!balanceColumn && prefs.columns.includes('balance'));
224
+
225
+ // ── Groups, newest first ──
226
+ const validStart = $derived(Number.isInteger(fyStart) && fyStart >= 1 && fyStart <= 12);
227
+ const fy = $derived(validStart ? fyStart : 7);
228
+ $effect(() => {
229
+ if (DEV && !validStart)
230
+ console.error(`Ledger: fyStart is a month from 1 to 12, not ${fyStart}; grouping from July.`);
231
+ });
232
+ const groups = $derived(groupRows(rows, prefs.period, fy));
233
+ /** Closed groups, keyed with their period so a new period starts open. */
234
+ const closed = new SvelteSet<string>();
235
+ const groupId = (key: string) => `${prefs.period}:${key}`;
236
+ /** Synthetic rows opened to their statement lines. */
237
+ const expanded = new SvelteSet<Id>();
238
+
239
+ function toggle<T>(set: SvelteSet<T>, value: T) {
240
+ if (set.has(value)) set.delete(value);
241
+ else set.add(value);
242
+ }
243
+
244
+ /** Closing a group closes the row open in it, and says so. */
245
+ function toggleGroup(g: { key: string; rows: readonly R[] }) {
246
+ const id = groupId(g.key);
247
+ if (!closed.has(id) && open !== null && g.rows.some((r) => r.id === open)) setOpen(null);
248
+ toggle(closed, id);
249
+ }
250
+
251
+ // ── Selection and the opened row ──
252
+ const selectable = $derived(!!bulkActions && !PHONE);
253
+ const openable = $derived(!!editor);
254
+ const ticked = $derived(new Set<Id>(selected));
255
+ const tickedRows = $derived(groups.flatMap((g) => g.rows).filter((r) => ticked.has(r.id)));
256
+ const inView = $derived(groups.filter((g) => !closed.has(groupId(g.key))).flatMap((g) => g.rows));
257
+ const allTicked = $derived(inView.length > 0 && inView.every((r) => ticked.has(r.id)));
258
+ const someTicked = $derived(!allTicked && inView.some((r) => ticked.has(r.id)));
259
+
260
+ // Reassigned rather than mutated: these are the consumer's bound values.
261
+ function tick(ids: Id[], on: boolean) {
262
+ const these = new Set(ids);
263
+ selected = on
264
+ ? [...selected, ...ids.filter((id) => !ticked.has(id))]
265
+ : selected.filter((id) => !these.has(id));
266
+ if (on) setOpen(null);
267
+ }
268
+
269
+ function setOpen(id: Id | null) {
270
+ if (open === id) return;
271
+ open = id;
272
+ if (id !== null) selected = [];
273
+ onOpenChange?.(id);
274
+ }
275
+
276
+ const context: LedgerOpenContext = {
277
+ close: () => setOpen(null),
278
+ get template() {
279
+ return template;
280
+ },
281
+ column
282
+ };
283
+
284
+ // ── Windowing: only the rows near the view mount ──
285
+ // Each item's height is measured once it renders and estimated until then;
286
+ // the rows above and below the window stand in as padding, so the scroll
287
+ // bar, the cards and the head keep their true geometry.
288
+ const items = $derived(
289
+ ledgerItems(groups, {
290
+ heads: prefs.period !== 'none',
291
+ closed: (key) => closed.has(groupId(key)),
292
+ open: openable ? open : null
293
+ })
294
+ );
295
+ const REM =
296
+ typeof document === 'undefined'
297
+ ? 16
298
+ : parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
299
+ const sizes = new Map<string, number>();
300
+ /** Bumped whenever a measurement lands, so the offsets recompute. */
301
+ let measured = $state(0);
302
+ let rowEstimate = $state(REM * 3.5);
303
+ let headEstimate = $state(REM * 3.125);
304
+ const sizeKey = (key: string) => `${mode}|${prefs.period}|${key}`;
305
+
306
+ const starts = $derived.by(() => {
307
+ void measured;
308
+ const out = new Float64Array(items.length + 1);
309
+ items.forEach((it, i) => {
310
+ let h = sizes.get(sizeKey(it.key));
311
+ if (it.kind === 'row') h = (h ?? rowEstimate) + (it.first ? 1 : 0) + (it.last ? 1 : 0);
312
+ else if (it.kind === 'head') h ??= it.group === 0 ? REM * 2 : headEstimate;
313
+ else h ??= REM * 6;
314
+ out[i + 1] = out[i]! + h;
315
+ });
316
+ return out;
317
+ });
318
+
319
+ let scrollTop = $state(0);
320
+ let viewHeight = $state(0);
321
+ let headHeight = $state(0);
322
+ const range = $derived.by(() => {
323
+ const view = viewHeight || (typeof window === 'undefined' ? 800 : window.innerHeight);
324
+ const over = Math.max(400, view / 2);
325
+ const top = scrollTop - (PHONE ? 0 : headHeight) - over;
326
+ return windowOf(starts, top, top + view + 2 * over);
327
+ });
328
+ const first = $derived(range[0]);
329
+ const last = $derived(range[1]);
330
+ const blocks = $derived(blocksOf(items, first, last));
331
+ const padTop = $derived(starts[first] ?? 0);
332
+ const padBottom = $derived(
333
+ Math.max(0, (starts[items.length] ?? 0) - (starts[Math.max(first, last + 1)] ?? 0))
334
+ );
335
+
336
+ const observer =
337
+ typeof ResizeObserver === 'undefined'
338
+ ? null
339
+ : new ResizeObserver((entries) => {
340
+ let changed = false;
341
+ for (const e of entries) {
342
+ const el = e.target as HTMLElement;
343
+ const key = el.dataset.ledgerSize;
344
+ const h = e.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight;
345
+ if (!key || !h || Math.abs((sizes.get(key) ?? -1) - h) < 0.5) continue;
346
+ sizes.set(key, h);
347
+ changed = true;
348
+ // A plain row (hairline, no lines opened) and a spaced head set the estimates.
349
+ if (el.dataset.estimate === 'row') rowEstimate = h;
350
+ if (el.dataset.estimate === 'head') headEstimate = h;
351
+ }
352
+ if (changed) measured++;
353
+ });
354
+ $effect(() => () => observer?.disconnect());
355
+
356
+ /** Measures an item as it renders, under the key its size is kept by. */
357
+ const measure =
358
+ (key: string, estimate?: 'row' | 'head'): Attachment<HTMLElement> =>
359
+ (node) => {
360
+ node.dataset.ledgerSize = sizeKey(key);
361
+ if (estimate) node.dataset.estimate = estimate;
362
+ observer?.observe(node);
363
+ return () => observer?.unobserve(node);
364
+ };
365
+
366
+ function totals(rs: R[]) {
367
+ let inn = 0;
368
+ let out = 0;
369
+ for (const r of rs) {
370
+ const c = cents(r.amount);
371
+ if (c > 0) inn += c;
372
+ else out += c;
373
+ }
374
+ return { net: (inn + out) / 100, inn: inn / 100, out: out / 100 };
375
+ }
376
+
377
+ const money = (value: number | string) => ledgerMoney(value, currency, locale);
378
+ const number = (n: number) => n.toLocaleString(locale);
379
+ const count = (n: number) => `${number(n)} ${n === 1 ? noun[0] : noun[1]}`;
380
+ const income = (value: number | string | null | undefined) => cents(value) > 0;
381
+ const reviewOf = (r: R) => (r.review === true ? 'warning' : r.review || null);
382
+ const balanceText = (r: R) =>
383
+ balanceColumn?.text ? balanceColumn.text(r) : r.balance == null ? '' : money(r.balance);
384
+ </script>
385
+
386
+ {#snippet clip(r: R)}
387
+ {#if onAttachment}
388
+ <button
389
+ type="button"
390
+ class={cn(
391
+ 'text-muted-foreground hover:text-foreground pointer-events-auto -m-0.5 grid size-5 flex-none place-items-center rounded-sm',
392
+ RING
393
+ )}
394
+ title={r.attachment}
395
+ aria-label="Open {r.attachment}"
396
+ onclick={() => onAttachment(r)}
397
+ data-slot="ledger-attachment"
398
+ >
399
+ <Paperclip class="size-3.5" />
400
+ </button>
401
+ {:else}
402
+ <span
403
+ class="text-muted-foreground pointer-events-auto grid flex-none place-items-center"
404
+ title={r.attachment}
405
+ role="img"
406
+ aria-label="Attached: {r.attachment}"
407
+ data-slot="ledger-attachment"
408
+ >
409
+ <Paperclip class="size-3.5" />
410
+ </span>
411
+ {/if}
412
+ {/snippet}
413
+
414
+ {#snippet badge(r: R)}
415
+ {@const on = expanded.has(r.id)}
416
+ <button
417
+ type="button"
418
+ class={cn(
419
+ 'text-2xs pointer-events-auto inline-flex h-4.5 flex-none items-center gap-1 rounded-sm border pr-1 pl-1.5 font-medium whitespace-nowrap',
420
+ RING,
421
+ on
422
+ ? 'border-border-strong bg-surface-3 text-foreground'
423
+ : 'border-border bg-surface-1 text-muted-foreground'
424
+ )}
425
+ aria-expanded={on}
426
+ title={on ? 'Hide the statement lines it is made of' : 'Show the statement lines it is made of'}
427
+ onclick={() => toggle(expanded, r.id)}
428
+ data-slot="ledger-not-printed"
429
+ >
430
+ Not as printed<ChevronDown class={cn('size-2.5 transition-transform', on && 'rotate-180')} />
431
+ </button>
432
+ {/snippet}
433
+
434
+ {#snippet dot(r: R, beside: boolean)}
435
+ {@const status = reviewOf(r)}
436
+ {#if status}
437
+ <span
438
+ class={cn(
439
+ 'ds-dot size-1.5',
440
+ `ds-dot-${status}`,
441
+ beside && 'absolute top-1/2 right-[calc(50%+0.75rem)] -translate-y-1/2'
442
+ )}
443
+ role="img"
444
+ aria-label="Needs review"
445
+ data-slot="ledger-review"
446
+ ></span>
447
+ {/if}
448
+ {/snippet}
449
+
450
+ {#snippet cells(r: R)}
451
+ {#each shown as c, i (c.key)}
452
+ {@const style = `grid-column: ${i + 2}; grid-row: 1;`}
453
+ {@const through = openable && THROUGH}
454
+ {#if c.key === 'date'}
455
+ <span class={cn(CELL, through, 'text-muted-foreground self-center tabular-nums')} {style}>
456
+ {ledgerDate(r.date, WIDE)}
457
+ </span>
458
+ {:else if c.key === 'title'}
459
+ <span
460
+ class={cn(
461
+ through,
462
+ 'flex min-w-0 flex-col justify-center gap-0.5 self-center px-2 font-medium'
463
+ )}
464
+ {style}
465
+ data-slot="ledger-title"
466
+ >
467
+ <span class="flex min-w-0 items-center gap-1.5">
468
+ <span class="min-w-0 truncate">{r.title}</span>
469
+ {#if r.attachment}{@render clip(r)}{/if}
470
+ </span>
471
+ {#if r.note || r.origins}
472
+ <span class="text-muted-foreground flex min-w-0 items-center gap-2 text-xs font-normal">
473
+ {#if r.origins}{@render badge(r)}{/if}
474
+ {#if r.note}<span class="min-w-0 truncate">{r.note}</span>{/if}
475
+ </span>
476
+ {/if}
477
+ </span>
478
+ {:else if c.key === 'amount'}
479
+ <span
480
+ class={cn(
481
+ CELL,
482
+ END,
483
+ through,
484
+ 'self-center tabular-nums',
485
+ income(r.amount) && 'text-status-success'
486
+ )}
487
+ {style}
488
+ data-slot="ledger-amount"
489
+ >
490
+ {money(r.amount)}
491
+ </span>
492
+ {:else if c.key === 'balance'}
493
+ <span class={cn(CELL, END, through, 'self-center tabular-nums')} {style}
494
+ >{balanceText(r)}</span
495
+ >
496
+ {:else if c.cell}
497
+ <span class={cn(CELL, through, 'flex items-center self-center')} {style}
498
+ >{@render c.cell(r)}</span
499
+ >
500
+ {:else}
501
+ <span class={cn(CELL, through, 'self-center', c.align === 'end' && END)} {style}>
502
+ {c.text?.(r) ?? ''}
503
+ </span>
504
+ {/if}
505
+ {/each}
506
+ {/snippet}
507
+
508
+ {#snippet origins(r: R)}
509
+ {@const og = r.origins}
510
+ {#if og && expanded.has(r.id)}
511
+ <div
512
+ class="bg-surface-1/60 border-border/60 flex flex-col border-t pb-2"
513
+ role="list"
514
+ aria-label="The statement lines it is made of"
515
+ data-slot="ledger-origins"
516
+ >
517
+ {#each og.lines as l, i (i)}
518
+ {#if PHONE}
519
+ <div role="listitem" class="flex items-center gap-3 px-3 py-1.5 text-xs">
520
+ <span class="text-muted-foreground tabular-nums">{ledgerDate(l.date, false)}</span>
521
+ <span class="flex min-w-0 flex-1 flex-col">
522
+ <span class="truncate">{l.description}</span>
523
+ {#if l.source}<span class="text-muted-foreground text-2xs truncate">{l.source}</span
524
+ >{/if}
525
+ </span>
526
+ <span
527
+ class={cn(
528
+ 'tabular-nums',
529
+ income(l.amount) ? 'text-status-success' : 'text-muted-foreground'
530
+ )}
531
+ >
532
+ {money(l.amount)}
533
+ </span>
534
+ </div>
535
+ {:else}
536
+ <div role="listitem" class="grid min-h-10 text-xs" style:grid-template-columns={template}>
537
+ {#if column('date')}
538
+ <span
539
+ class={cn(CELL, 'text-muted-foreground self-center tabular-nums')}
540
+ style="grid-column: {column('date')};"
541
+ >
542
+ {ledgerDate(l.date, WIDE)}
543
+ </span>
544
+ {/if}
545
+ <span
546
+ class="border-border-strong ml-4.5 flex min-w-0 flex-col justify-center gap-px border-l-2 py-1.5 pr-2 pl-2.5"
547
+ style="grid-column: {column('title')} / {cAmount};"
548
+ >
549
+ <span class="truncate" title={l.description}>{l.description}</span>
550
+ {#if l.source}<span class="text-muted-foreground text-2xs truncate">{l.source}</span
551
+ >{/if}
552
+ </span>
553
+ <span
554
+ class={cn(
555
+ CELL,
556
+ END,
557
+ 'self-center tabular-nums',
558
+ income(l.amount) ? 'text-status-success' : 'text-muted-foreground'
559
+ )}
560
+ style="grid-column: {cAmount};"
561
+ >
562
+ {money(l.amount)}
563
+ </span>
564
+ </div>
565
+ {/if}
566
+ {/each}
567
+ {#if og.summary || originActions}
568
+ <!-- Under the lines' own text: the indent, the rule and its padding. -->
569
+ <div class="grid pt-1.5" style:grid-template-columns={PHONE ? '1fr' : template}>
570
+ <div
571
+ class={cn(
572
+ 'flex min-w-0 items-center gap-2 pr-3',
573
+ PHONE ? 'pl-3' : 'pl-[calc(1.75rem+2px)]'
574
+ )}
575
+ style:grid-column={PHONE ? undefined : `${column('title')} / -1`}
576
+ >
577
+ <span class="text-muted-foreground min-w-0 flex-1 truncate text-xs"
578
+ >{og.summary ?? ''}</span
579
+ >
580
+ {#if originActions}{@render originActions(r)}{/if}
581
+ </div>
582
+ </div>
583
+ {/if}
584
+ </div>
585
+ {/if}
586
+ {/snippet}
587
+
588
+ {#snippet row(r: R, first: boolean)}
589
+ {@const on = ticked.has(r.id)}
590
+ {@const label = `${r.title}, ${ledgerDate(r.date)}, ${money(r.amount)}`}
591
+ {#if PHONE}
592
+ {@const two = [BY_DAY ? '' : ledgerDate(r.date, false), r.note ?? '']
593
+ .filter(Boolean)
594
+ .join(' · ')}
595
+ <div
596
+ class={cn(
597
+ 'relative grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2 text-sm',
598
+ !first && 'border-border/60 border-t',
599
+ openable && 'hover:bg-accent/40'
600
+ )}
601
+ style={ROW_HEIGHT}
602
+ data-slot="ledger-row"
603
+ >
604
+ {#if openable}
605
+ <button
606
+ type="button"
607
+ class={cn('absolute inset-0 cursor-pointer', RING_INSET)}
608
+ aria-label="Open {label}"
609
+ onclick={() => setOpen(r.id)}
610
+ ></button>
611
+ {/if}
612
+ <span class="pointer-events-none flex min-w-0 flex-col justify-center gap-0.5">
613
+ <span class="flex min-w-0 items-center gap-1.5">
614
+ {@render dot(r, false)}
615
+ <span class="min-w-0 truncate font-medium">{r.title}</span>
616
+ {#if r.attachment}{@render clip(r)}{/if}
617
+ </span>
618
+ {#if two || r.origins}
619
+ <span class="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
620
+ {#if r.origins}{@render badge(r)}{/if}
621
+ <span class="min-w-0 truncate">{two}</span>
622
+ </span>
623
+ {/if}
624
+ </span>
625
+ <span class="pointer-events-none flex flex-col items-end justify-center gap-0.5 tabular-nums">
626
+ <span class={cn(income(r.amount) && 'text-status-success')} data-slot="ledger-amount"
627
+ >{money(r.amount)}</span
628
+ >
629
+ {#if balanceOn && balanceText(r)}<span class="text-muted-foreground text-xs"
630
+ >{balanceText(r)}</span
631
+ >{/if}
632
+ </span>
633
+ </div>
634
+ {:else}
635
+ <div
636
+ class={cn(
637
+ 'relative grid items-stretch text-[0.8125rem]',
638
+ !first && 'border-border/60 border-t',
639
+ on && 'bg-primary/9',
640
+ openable && !on && 'hover:bg-accent/40'
641
+ )}
642
+ style="grid-template-columns: {template}; {ROW_HEIGHT}"
643
+ data-slot="ledger-row"
644
+ >
645
+ <span class="relative flex items-center justify-center" style="grid-column: 1; grid-row: 1;">
646
+ {@render dot(r, selectable)}
647
+ {#if selectable}
648
+ <Checkbox
649
+ checked={on}
650
+ aria-label="Select {label}"
651
+ onCheckedChange={(v) => tick([r.id], v === true)}
652
+ />
653
+ {/if}
654
+ </span>
655
+ {#if openable}
656
+ <button
657
+ type="button"
658
+ class={cn('cursor-pointer', RING_INSET)}
659
+ style="grid-column: 2 / -1; grid-row: 1;"
660
+ aria-label="Open {label}"
661
+ onclick={() => setOpen(r.id)}
662
+ ></button>
663
+ {/if}
664
+ {@render cells(r)}
665
+ </div>
666
+ {/if}
667
+ {@render origins(r)}
668
+ {/snippet}
669
+
670
+ {#snippet groupHead(g: { key: string; rows: R[] }, gi: number)}
671
+ {@const key = g.key}
672
+ {@const rs = g.rows}
673
+ {@const isOpen = !closed.has(groupId(key))}
674
+ {@const t = totals(rs)}
675
+ {@const single = BY_DAY && rs.length === 1}
676
+ {@const label = periodLabel(
677
+ key,
678
+ prefs.period,
679
+ fy,
680
+ !(mode === 'narrow' && (BY_DAY || prefs.period === 'week'))
681
+ )}
682
+ {@const span = WIDE && prefs.period === 'fy' ? `${fySpan(key, fy)} · ` : ''}
683
+ {@const metaText = single ? '' : PHONE ? number(rs.length) : span + count(rs.length)}
684
+ {@const net = single ? '' : money(t.net)}
685
+ {@const netTone = t.net > 0 ? 'text-status-success' : 'text-muted-foreground'}
686
+ {@const klass = cn(
687
+ 'text-muted-foreground h-6.5 w-full text-left text-xs',
688
+ PHONE ? 'flex items-center gap-2 px-0.5' : 'grid items-center px-px'
689
+ )}
690
+ <!-- The spacing is padding, not margin, so the measured height is the height it takes. -->
691
+ <div
692
+ class={cn('pb-1.5', gi > 0 && 'pt-4.5')}
693
+ {@attach measure(`h:${key}`, gi > 0 ? 'head' : undefined)}
694
+ >
695
+ <!-- A day heads its rows and does not close; any other period's label opens and closes it. -->
696
+ {#if BY_DAY}
697
+ <div
698
+ class={klass}
699
+ style:grid-template-columns={PHONE ? undefined : template}
700
+ data-slot="ledger-group"
701
+ >
702
+ {@render groupInner(label, metaText, net, netTone, t, isOpen)}
703
+ </div>
704
+ {:else}
705
+ <button
706
+ type="button"
707
+ class={cn(klass, 'cursor-pointer rounded-md', RING)}
708
+ aria-expanded={isOpen}
709
+ onclick={() => toggleGroup(g)}
710
+ style:grid-template-columns={PHONE ? undefined : template}
711
+ data-slot="ledger-group"
712
+ >
713
+ {@render groupInner(label, metaText, net, netTone, t, isOpen)}
714
+ </button>
715
+ {/if}
716
+ </div>
717
+ {/snippet}
718
+
719
+ {#snippet groupInner(
720
+ label: string,
721
+ metaText: string,
722
+ net: string,
723
+ netTone: string,
724
+ t: { inn: number; out: number },
725
+ isOpen: boolean
726
+ )}
727
+ {#if PHONE}
728
+ {#if !BY_DAY}<ChevronRight
729
+ class={cn('size-3.5 flex-none transition-transform', isOpen && 'rotate-90')}
730
+ />{/if}
731
+ <span class="text-[0.8125rem] font-semibold whitespace-nowrap">{label}</span>
732
+ <span class="min-w-0 flex-1 truncate">{metaText}</span>
733
+ <span class={cn('tabular-nums', netTone)}>{net}</span>
734
+ {:else}
735
+ <span class="grid place-items-center" style="grid-column: 1;">
736
+ {#if !BY_DAY}<ChevronRight
737
+ class={cn('size-3.5 transition-transform', isOpen && 'rotate-90')}
738
+ />{/if}
739
+ </span>
740
+ <span
741
+ class="flex min-w-0 items-baseline gap-3 overflow-hidden px-2 whitespace-nowrap"
742
+ style="grid-column: 2 / {cAmount};"
743
+ >
744
+ <span class="text-[0.8125rem] font-semibold">{label}</span>
745
+ <span class="min-w-0 truncate">{metaText}</span>
746
+ </span>
747
+ <span class={cn(CELL, END, 'tabular-nums', netTone)} style="grid-column: {cAmount};">{net}</span
748
+ >
749
+ {#if WIDE && afterAmount > 0 && !BY_DAY}
750
+ <span class={CELL} style="grid-column: {cAmount + 1} / -1;">
751
+ In {money(t.inn)} · out {money(t.out)}
752
+ </span>
753
+ {/if}
754
+ {/if}
755
+ {/snippet}
756
+
757
+ <div
758
+ bind:clientWidth={width}
759
+ class={cn('relative flex min-h-0 flex-1 flex-col', className)}
760
+ data-slot="ledger"
761
+ data-layout={mode}
762
+ {...restProps}
763
+ >
764
+ {#if selectable && tickedRows.length}
765
+ <div
766
+ role="toolbar"
767
+ aria-label="Act on the ticked rows"
768
+ class="bg-card border-border-strong mx-2 mb-2.5 flex h-11 flex-none items-center gap-2 rounded-lg border pr-3 pl-1"
769
+ style="background: color-mix(in oklch, var(--color-primary) 9%, var(--color-card));"
770
+ style:margin-right={edge}
771
+ data-slot="ledger-bulk"
772
+ >
773
+ <span class="grid size-8 place-items-center">
774
+ <Checkbox checked aria-label="Clear the ticks" onCheckedChange={() => (selected = [])} />
775
+ </span>
776
+ <span class="text-sm font-semibold whitespace-nowrap">{number(tickedRows.length)} ticked</span
777
+ >
778
+ <span class="text-muted-foreground mr-2 text-xs whitespace-nowrap tabular-nums">
779
+ net {money(tickedRows.reduce((s, r) => s + cents(r.amount), 0) / 100)}
780
+ </span>
781
+ {@render bulkActions?.(tickedRows)}
782
+ <span class="flex-1"></span>
783
+ <Button
784
+ variant="ghost"
785
+ size="icon-sm"
786
+ aria-label="Clear the ticks"
787
+ onclick={() => (selected = [])}
788
+ >
789
+ <X />
790
+ </Button>
791
+ </div>
792
+ {:else}
793
+ <div
794
+ role="toolbar"
795
+ aria-label="This ledger"
796
+ class="flex min-h-11 flex-none items-center gap-2 px-2 pb-2"
797
+ style:padding-right={edge}
798
+ data-slot="ledger-toolbar"
799
+ >
800
+ <span class="flex min-w-0 items-baseline gap-2.5 overflow-hidden whitespace-nowrap">
801
+ {#if title}<span class="text-body flex-none font-semibold">{title}</span>{/if}
802
+ <span class="text-muted-foreground truncate text-sm">{meta ?? count(rows.length)}</span>
803
+ </span>
804
+ <span class="min-w-0 flex-1"></span>
805
+ <LedgerColumns
806
+ offered={resolved.map((c) => ({ key: c.key, label: c.label, locked: c.locked }))}
807
+ preferences={prefs}
808
+ note={preferencesNote}
809
+ onchange={setPreferences}
810
+ onreset={() => setPreferences(defaults)}
811
+ />
812
+ {@render actions?.()}
813
+ </div>
814
+ {/if}
815
+
816
+ <div
817
+ bind:offsetWidth={listOuter}
818
+ bind:clientWidth={listInner}
819
+ bind:clientHeight={viewHeight}
820
+ onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
821
+ class="relative min-h-0 flex-1 overflow-auto px-2 pb-3 [scrollbar-width:thin]"
822
+ style="scrollbar-gutter: stable;"
823
+ data-slot="ledger-rows"
824
+ >
825
+ {#if !PHONE}
826
+ <div class="bg-background sticky top-0 z-3 pb-3" bind:offsetHeight={headHeight}>
827
+ <div
828
+ class="bg-card border-border grid h-9 items-center rounded-lg border"
829
+ style:grid-template-columns={template}
830
+ data-slot="ledger-head"
831
+ >
832
+ <span class="grid place-items-center">
833
+ {#if selectable}
834
+ <Checkbox
835
+ checked={allTicked}
836
+ indeterminate={someTicked}
837
+ aria-label="Select every {noun[0]} shown"
838
+ onCheckedChange={(v) =>
839
+ tick(
840
+ inView.map((r) => r.id),
841
+ v === true
842
+ )}
843
+ />
844
+ {/if}
845
+ </span>
846
+ {#each shown as c (c.key)}
847
+ <span
848
+ class={cn(
849
+ CELL,
850
+ 'text-muted-foreground text-2xs tracking-eyebrow font-semibold uppercase',
851
+ c.align === 'end' && END
852
+ )}
853
+ >
854
+ {c.head ?? c.label}
855
+ </span>
856
+ {/each}
857
+ </div>
858
+ </div>
859
+ {/if}
860
+
861
+ {#if rows.length === 0}
862
+ {#if empty}{@render empty()}{:else}<EmptyState title="No {noun[1]}" />{/if}
863
+ {:else}
864
+ <div
865
+ style:padding-top="{padTop}px"
866
+ style:padding-bottom="{padBottom}px"
867
+ data-slot="ledger-body"
868
+ >
869
+ {#each blocks as block (block.key)}
870
+ {#if block.kind === 'head'}
871
+ {@render groupHead(groups[block.group]!, block.group)}
872
+ {:else if block.kind === 'open'}
873
+ <div class="py-2" {@attach measure(block.key)}>
874
+ <div
875
+ class="bg-popover border-border-strong rounded-lg border shadow-md"
876
+ data-slot="ledger-open"
877
+ >
878
+ {@render editor?.(block.row, context)}
879
+ </div>
880
+ </div>
881
+ {:else}
882
+ <!-- A card the window cuts keeps no edge at the cut, which is out of view. -->
883
+ <div
884
+ class={cn(
885
+ 'bg-card border-border overflow-hidden rounded-lg border',
886
+ block.cutTop && 'rounded-t-none border-t-0',
887
+ block.cutBottom && 'rounded-b-none border-b-0'
888
+ )}
889
+ data-slot="ledger-card"
890
+ >
891
+ {#each block.rows as it (it.key)}
892
+ <div
893
+ {@attach measure(it.key, it.first || expanded.has(it.row.id) ? undefined : 'row')}
894
+ >
895
+ {@render row(it.row, it.first)}
896
+ </div>
897
+ {/each}
898
+ </div>
899
+ {/if}
900
+ {/each}
901
+ </div>
902
+ {/if}
903
+ {@render footer?.()}
904
+ </div>
905
+ </div>