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