@poodle64/ui 2026.10.0 → 2026.10.2

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