@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.
- package/README.md +84 -6
- package/dist/components/settings/index.d.ts +3 -0
- package/dist/components/settings/index.js +3 -0
- package/dist/components/settings/nest.d.ts +15 -0
- package/dist/components/settings/nest.js +58 -0
- package/dist/components/settings/settings-page.svelte +159 -0
- package/dist/components/settings/settings-page.svelte.d.ts +17 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/ledger/index.d.ts +3 -0
- package/dist/components/ui/ledger/index.js +2 -0
- package/dist/components/ui/ledger/ledger-columns.svelte +107 -0
- package/dist/components/ui/ledger/ledger-columns.svelte.d.ts +15 -0
- package/dist/components/ui/ledger/ledger.d.ts +77 -0
- package/dist/components/ui/ledger/ledger.js +221 -0
- package/dist/components/ui/ledger/ledger.svelte +905 -0
- package/dist/components/ui/ledger/ledger.svelte.d.ts +78 -0
- package/dist/components/ui/ledger/types.d.ts +79 -0
- package/dist/components/ui/ledger/types.js +1 -0
- package/dist/format.d.ts +8 -0
- package/dist/format.js +9 -3
- package/package.json +6 -1
- package/registry/component-map.json +17 -3
- package/registry/component-map.md +9 -1
|
@@ -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>
|