@heyramzi/ui 2.2.0 → 2.4.0
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/COMPONENTS.md +1 -1
- package/package.json +1 -1
- package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
- package/src/canvas/ops/ops.ts +1 -1
- package/src/components/table-view/index.ts +1 -1
- package/src/components/table-view/table-view.svelte +351 -11
- package/src/components/table-view/table-view.test.ts +101 -0
- package/src/components/table-view/types.ts +11 -0
- package/src/icons/trades.ts +1 -1
package/COMPONENTS.md
CHANGED
|
@@ -76,7 +76,7 @@ Generic pieces built on the primitives, imported from `@heyramzi/ui/components/<
|
|
|
76
76
|
- `section-card` — SectionCard, a card with a fixed-height header, an h3 title, optional actions and a line under it
|
|
77
77
|
- `section-header` — SectionHeader, a section label with a count chip, a line of description and actions
|
|
78
78
|
- `field-row` — FieldRow, a labelled control with its action button at the control's height
|
|
79
|
-
- `table-view` — TableView, a read-only table from columns and rows: numeric columns, footer row, custom cell
|
|
79
|
+
- `table-view` — TableView, a read-only table from columns and rows: numeric columns, footer row, custom cell; opt-in: `bare`, sortable heads (`column.sortable`, bind `sort`; the caller orders `rows`), `rowHref` (a real link over the row; `onRowLink` opens in place on a plain click), `onRowClick`, `selectable` + bind `selected`, `rowMenu` (context-menu items), `group` (a `null` key sits with no header) + `groupHeader` + `collapsible`, `subRow` + `expanded`, `loading` (skeleton rows in the frame), `empty`, `trailing`, `head`, and per-column `hideBelow`, `sticky`, `interactive`, `class`
|
|
80
80
|
- `segmented` — AnimatedTabs (pill or underline), SegmentedFilter, SegmentedNav, SectionRuns, one bordered track with a sliding pill, a badge note and an overflow menu
|
|
81
81
|
- `theme` — ThemeProvider, mode, userPrefersMode, setMode, toggleMode, resetMode: the one light and dark system (mode-watcher, optional peer). Mount `<ThemeProvider initial onChange themeColors modeStorageKey />` once; flip with `toggleMode()`, read `mode.current`. Pair with `theme-toggle`'s `revealTheme(event, toggleMode)` for the wipe
|
|
82
82
|
- `star-rating-input` — StarRatingInput
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus tasks, charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -3,7 +3,7 @@ import { sortParentsFirst, stripCallbacks } from "./canvas-cleanup.js";
|
|
|
3
3
|
import { makeNode } from "../test-utils.js";
|
|
4
4
|
import type { CanvasNode } from "../types.js";
|
|
5
5
|
|
|
6
|
-
/** Read serialized data as a loose record: these tests exercise
|
|
6
|
+
/** Read serialized data as a loose record: these tests exercise old fields
|
|
7
7
|
* that are no longer part of any per-type shape but must survive the denylist. */
|
|
8
8
|
function loose(node: { data: unknown }): Record<string, unknown> {
|
|
9
9
|
return node.data as Record<string, unknown>;
|
package/src/canvas/ops/ops.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const patchNodeOp = z.object({
|
|
|
36
36
|
kind: z.literal("patchNode"),
|
|
37
37
|
nodeId: z.string().min(1),
|
|
38
38
|
data: z.record(z.string(), z.unknown()).optional(),
|
|
39
|
-
// Merged into the node's style object. A
|
|
39
|
+
// Merged into the node's style object. A CSS-string style from an old save is replaced
|
|
40
40
|
// wholesale (there is nothing sane to merge into).
|
|
41
41
|
style: z.record(z.string(), z.unknown()).optional(),
|
|
42
42
|
width: z.number().optional(),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { TableColumn } from "./types.js";
|
|
4
4
|
|
|
5
5
|
export type TableCell<Row> = { row: Row; column: TableColumn<Row>; value: unknown; text: string };
|
|
6
|
+
export type TableGroup<Row> = { key: string; rows: Row[] };
|
|
6
7
|
export type { TableColumn };
|
|
7
8
|
</script>
|
|
8
9
|
|
|
@@ -10,8 +11,13 @@
|
|
|
10
11
|
// =========================
|
|
11
12
|
// IMPORTS
|
|
12
13
|
// =========================
|
|
14
|
+
import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
|
|
13
15
|
import { cn } from "../../lib/utils.js";
|
|
14
16
|
import * as Table from "../../shadcn/table/index.js";
|
|
17
|
+
import * as ContextMenu from "../../shadcn/context-menu/index.js";
|
|
18
|
+
import { Checkbox } from "../../shadcn/checkbox/index.js";
|
|
19
|
+
import { TableSkeleton } from "../data-table/index.js";
|
|
20
|
+
import { toggleSort, type SortState } from "../data-table/data-table.js";
|
|
15
21
|
|
|
16
22
|
// =========================
|
|
17
23
|
// TYPES
|
|
@@ -30,7 +36,48 @@
|
|
|
30
36
|
rowClass?: (row: Row) => string | undefined;
|
|
31
37
|
/** Below this width the table scrolls sideways inside its frame. */
|
|
32
38
|
minWidth?: string;
|
|
39
|
+
/** No outer border or radius, for a table inside a card. */
|
|
40
|
+
bare?: boolean;
|
|
33
41
|
class?: string;
|
|
42
|
+
/** The sorted column and its direction. Bind it; columns with `sortable` set it. */
|
|
43
|
+
sort?: SortState<Row>;
|
|
44
|
+
/** Where a row goes. Draws a real link over the row, so middle-click and cmd-click work. */
|
|
45
|
+
rowHref?: (row: Row) => string | undefined;
|
|
46
|
+
/** Runs on a plain left click of the row link. Call `event.preventDefault()` to open in place; a modified click still follows `href`. */
|
|
47
|
+
onRowLink?: (row: Row, event: MouseEvent) => void;
|
|
48
|
+
/** The link's accessible name. Defaults to the row's first column text. */
|
|
49
|
+
rowLabel?: (row: Row) => string;
|
|
50
|
+
/** Adds a checkbox column with a select-all in the head. */
|
|
51
|
+
selectable?: boolean;
|
|
52
|
+
/** The `rowKey` of every checked row. Bind it. */
|
|
53
|
+
selected?: Set<string>;
|
|
54
|
+
selectAllLabel?: string;
|
|
55
|
+
selectRowLabel?: (row: Row) => string;
|
|
56
|
+
/** The items of a right-click menu for one row: `ContextMenu.Item`s. */
|
|
57
|
+
rowMenu?: Snippet<[Row]>;
|
|
58
|
+
/** Rows that return the same key sit under one header row, and `null` rows sit with no header. Order `rows` so a group is contiguous. */
|
|
59
|
+
group?: (row: Row) => string | null;
|
|
60
|
+
/** Draws a group's header. Prints the key when left out. */
|
|
61
|
+
groupHeader?: Snippet<[TableGroup<Row>]>;
|
|
62
|
+
/** Makes each group header a button that folds its rows. */
|
|
63
|
+
collapsible?: boolean;
|
|
64
|
+
/** The keys of the folded groups. Bind it. */
|
|
65
|
+
collapsed?: Set<string>;
|
|
66
|
+
/** Draws a column head its own way, an icon or a colour. Print `column.label` for the plain case. */
|
|
67
|
+
head?: Snippet<[TableColumn<Row>]>;
|
|
68
|
+
/** The row opens in place. Click or Enter on the row, unless the target is a link, button or input inside it. */
|
|
69
|
+
onRowClick?: (row: Row) => void;
|
|
70
|
+
/** Whether a row shows its `subRow`. */
|
|
71
|
+
expanded?: (row: Row) => boolean;
|
|
72
|
+
/** A full-width row under the row, for detail. */
|
|
73
|
+
subRow?: Snippet<[Row]>;
|
|
74
|
+
/** Drawn across every column when there are no rows. */
|
|
75
|
+
empty?: Snippet;
|
|
76
|
+
/** Drawn across every column under the last row: a load-more sentinel or a spinner. */
|
|
77
|
+
trailing?: Snippet;
|
|
78
|
+
/** Draws placeholder rows in the same frame instead of `rows`. */
|
|
79
|
+
loading?: boolean;
|
|
80
|
+
skeletonRows?: number;
|
|
34
81
|
}
|
|
35
82
|
|
|
36
83
|
// =========================
|
|
@@ -45,9 +92,60 @@
|
|
|
45
92
|
cell,
|
|
46
93
|
rowClass,
|
|
47
94
|
minWidth = "0",
|
|
95
|
+
bare = false,
|
|
48
96
|
class: className,
|
|
97
|
+
sort = $bindable(null),
|
|
98
|
+
rowHref,
|
|
99
|
+
onRowLink,
|
|
100
|
+
rowLabel,
|
|
101
|
+
selectable = false,
|
|
102
|
+
selected = $bindable(new Set<string>()),
|
|
103
|
+
selectAllLabel = "Select all rows",
|
|
104
|
+
selectRowLabel,
|
|
105
|
+
rowMenu,
|
|
106
|
+
group,
|
|
107
|
+
groupHeader,
|
|
108
|
+
collapsible = false,
|
|
109
|
+
collapsed = $bindable(new Set<string>()),
|
|
110
|
+
head,
|
|
111
|
+
onRowClick,
|
|
112
|
+
expanded,
|
|
113
|
+
subRow,
|
|
114
|
+
empty,
|
|
115
|
+
trailing,
|
|
116
|
+
loading = false,
|
|
117
|
+
skeletonRows = 5,
|
|
49
118
|
}: Props = $props();
|
|
50
119
|
|
|
120
|
+
// =========================
|
|
121
|
+
// DERIVED
|
|
122
|
+
// =========================
|
|
123
|
+
let span = $derived(columns.length + (selectable ? 1 : 0));
|
|
124
|
+
let selectedCount = $derived(rows.filter((row) => selected.has(rowKey(row))).length);
|
|
125
|
+
let allSelected = $derived(rows.length > 0 && selectedCount === rows.length);
|
|
126
|
+
let someSelected = $derived(selectedCount > 0 && !allSelected);
|
|
127
|
+
let skeletonColumns = $derived([
|
|
128
|
+
...(selectable ? [{ width: "w-4" }] : []),
|
|
129
|
+
...columns.map((column) => ({
|
|
130
|
+
align: column.numeric ? ("right" as const) : ("left" as const),
|
|
131
|
+
})),
|
|
132
|
+
]);
|
|
133
|
+
type Run = { id: string; key: string | null; rows: Row[] };
|
|
134
|
+
let entries = $derived.by<Run[]>(() => {
|
|
135
|
+
if (!group) return [];
|
|
136
|
+
const out: Run[] = [];
|
|
137
|
+
let current: Run | undefined;
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const groupKey = group(row);
|
|
140
|
+
if (!current || current.key !== groupKey) {
|
|
141
|
+
current = { id: groupKey ?? `\u0000${out.length}`, key: groupKey, rows: [] };
|
|
142
|
+
out.push(current);
|
|
143
|
+
}
|
|
144
|
+
current.rows.push(row);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
|
|
51
149
|
// =========================
|
|
52
150
|
// FUNCTIONS
|
|
53
151
|
// =========================
|
|
@@ -58,6 +156,67 @@
|
|
|
58
156
|
if (column.format) return column.format(value, row);
|
|
59
157
|
return value === null || value === undefined ? "" : String(value);
|
|
60
158
|
}
|
|
159
|
+
|
|
160
|
+
function ariaSort(column: TableColumn<Row>): "ascending" | "descending" | "none" | undefined {
|
|
161
|
+
if (!column.sortable) return undefined;
|
|
162
|
+
if (sort?.key !== column.key) return "none";
|
|
163
|
+
return sort.direction === "asc" ? "ascending" : "descending";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toggleRow(row: Row, on: boolean) {
|
|
167
|
+
const next = new Set(selected);
|
|
168
|
+
if (on) next.add(rowKey(row));
|
|
169
|
+
else next.delete(rowKey(row));
|
|
170
|
+
selected = next;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toggleAll(on: boolean) {
|
|
174
|
+
const next = new Set(selected);
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
if (on) next.add(rowKey(row));
|
|
177
|
+
else next.delete(rowKey(row));
|
|
178
|
+
}
|
|
179
|
+
selected = next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const HIDE = {
|
|
183
|
+
sm: "hidden sm:table-cell",
|
|
184
|
+
md: "hidden md:table-cell",
|
|
185
|
+
lg: "hidden lg:table-cell",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function toggleGroup(groupKey: string) {
|
|
189
|
+
const next = new Set(collapsed);
|
|
190
|
+
if (next.has(groupKey)) next.delete(groupKey);
|
|
191
|
+
else next.add(groupKey);
|
|
192
|
+
collapsed = next;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// WHY the target check: a row can hold its own link, button or checkbox, and a click on one of those belongs to it.
|
|
196
|
+
const OWN_CLICK =
|
|
197
|
+
"a, button, input, select, textarea, label, [role=checkbox], [role=button], [role=menuitem]";
|
|
198
|
+
|
|
199
|
+
function rowClick(event: MouseEvent, row: Row) {
|
|
200
|
+
if ((event.target as Element).closest(OWN_CLICK)) return;
|
|
201
|
+
onRowClick?.(row);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function rowKeydown(event: KeyboardEvent, row: Row) {
|
|
205
|
+
if (event.target !== event.currentTarget) return;
|
|
206
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
onRowClick?.(row);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function linkClick(event: MouseEvent, row: Row) {
|
|
212
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
213
|
+
return;
|
|
214
|
+
onRowLink?.(row, event);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function linkName(row: Row): string {
|
|
218
|
+
return rowLabel?.(row) ?? (columns[0] ? textOf(columns[0], row) : label);
|
|
219
|
+
}
|
|
61
220
|
</script>
|
|
62
221
|
|
|
63
222
|
<!-- =========================== -->
|
|
@@ -70,6 +229,10 @@
|
|
|
70
229
|
"align-top",
|
|
71
230
|
column.numeric ? "text-end tabular-nums" : "whitespace-normal",
|
|
72
231
|
column.muted && "text-muted-foreground",
|
|
232
|
+
column.interactive && "relative z-10",
|
|
233
|
+
column.hideBelow && HIDE[column.hideBelow],
|
|
234
|
+
column.sticky && "bg-background sticky left-0 z-10",
|
|
235
|
+
column.class,
|
|
73
236
|
)}
|
|
74
237
|
>
|
|
75
238
|
{#if cell}
|
|
@@ -86,31 +249,208 @@
|
|
|
86
249
|
{/each}
|
|
87
250
|
{/snippet}
|
|
88
251
|
|
|
89
|
-
|
|
90
|
-
|
|
252
|
+
{#snippet rowOrMenu(row: Row)}
|
|
253
|
+
{#if rowMenu}
|
|
254
|
+
<ContextMenu.Root>
|
|
255
|
+
<ContextMenu.Trigger>
|
|
256
|
+
{#snippet child({ props })}
|
|
257
|
+
{@render dataRow(row, props)}
|
|
258
|
+
{/snippet}
|
|
259
|
+
</ContextMenu.Trigger>
|
|
260
|
+
<ContextMenu.Content class="w-48">
|
|
261
|
+
{@render rowMenu(row)}
|
|
262
|
+
</ContextMenu.Content>
|
|
263
|
+
</ContextMenu.Root>
|
|
264
|
+
{:else}
|
|
265
|
+
{@render dataRow(row, {})}
|
|
266
|
+
{/if}
|
|
267
|
+
{/snippet}
|
|
268
|
+
|
|
269
|
+
{#snippet dataRow(row: Row, triggerProps: Record<string, unknown>)}
|
|
270
|
+
{@const href = rowHref?.(row)}
|
|
271
|
+
{@const isSelected = selectable && selected.has(rowKey(row))}
|
|
272
|
+
<Table.Row
|
|
273
|
+
{...triggerProps}
|
|
274
|
+
data-state={isSelected ? "selected" : undefined}
|
|
275
|
+
tabindex={onRowClick ? 0 : undefined}
|
|
276
|
+
onclick={onRowClick ? (event: MouseEvent) => rowClick(event, row) : undefined}
|
|
277
|
+
onkeydown={onRowClick ? (event: KeyboardEvent) => rowKeydown(event, row) : undefined}
|
|
278
|
+
class={cn(
|
|
279
|
+
href && "relative t-press active:bg-muted/50",
|
|
280
|
+
onRowClick &&
|
|
281
|
+
"t-press active:bg-muted/50 focus-visible:ring-ring cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset",
|
|
282
|
+
rowClass?.(row),
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
{#if selectable}
|
|
286
|
+
<Table.Cell class="relative z-10 w-10 align-top">
|
|
287
|
+
<Checkbox
|
|
288
|
+
checked={isSelected}
|
|
289
|
+
onCheckedChange={(on) => toggleRow(row, on === true)}
|
|
290
|
+
aria-label={selectRowLabel?.(row) ?? `Select ${linkName(row)}`}
|
|
291
|
+
/>
|
|
292
|
+
</Table.Cell>
|
|
293
|
+
{/if}
|
|
294
|
+
{@render body(row)}
|
|
295
|
+
{#if href}
|
|
296
|
+
<!-- WHY an empty anchor and not a wrapped cell: cells hold buttons and links of their own, and an anchor can't nest one. -->
|
|
297
|
+
<td class="p-0">
|
|
298
|
+
<a
|
|
299
|
+
{href}
|
|
300
|
+
aria-label={linkName(row)}
|
|
301
|
+
onclick={onRowLink ? (event: MouseEvent) => linkClick(event, row) : undefined}
|
|
302
|
+
class="absolute inset-0 z-0 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
303
|
+
></a>
|
|
304
|
+
</td>
|
|
305
|
+
{/if}
|
|
306
|
+
</Table.Row>
|
|
307
|
+
{#if subRow && expanded?.(row)}
|
|
308
|
+
<Table.Row class="bg-muted/20 hover:bg-muted/20 hover:translate-y-0 hover:shadow-none">
|
|
309
|
+
<Table.Cell colspan={span + (rowHref ? 1 : 0)} class="whitespace-normal">
|
|
310
|
+
{@render subRow(row)}
|
|
311
|
+
</Table.Cell>
|
|
312
|
+
</Table.Row>
|
|
313
|
+
{/if}
|
|
314
|
+
{/snippet}
|
|
315
|
+
|
|
316
|
+
<div class={cn(!bare && "overflow-hidden rounded-xl border", className)}>
|
|
317
|
+
<Table.Root
|
|
318
|
+
aria-label={label}
|
|
319
|
+
aria-busy={loading || undefined}
|
|
320
|
+
style={`min-width: ${minWidth}`}
|
|
321
|
+
class="min-w-0"
|
|
322
|
+
>
|
|
91
323
|
<Table.Header>
|
|
92
324
|
<Table.Row>
|
|
325
|
+
{#if selectable}
|
|
326
|
+
<Table.Head class="w-10">
|
|
327
|
+
<Checkbox
|
|
328
|
+
checked={allSelected}
|
|
329
|
+
indeterminate={someSelected}
|
|
330
|
+
onCheckedChange={(on) => toggleAll(on === true)}
|
|
331
|
+
aria-label={selectAllLabel}
|
|
332
|
+
disabled={loading || rows.length === 0}
|
|
333
|
+
/>
|
|
334
|
+
</Table.Head>
|
|
335
|
+
{/if}
|
|
93
336
|
{#each columns as column (column.key)}
|
|
94
337
|
<Table.Head
|
|
95
|
-
class={cn(
|
|
338
|
+
class={cn(
|
|
339
|
+
"whitespace-normal",
|
|
340
|
+
column.numeric && "text-end",
|
|
341
|
+
column.sortable && "h-10 p-0",
|
|
342
|
+
column.hideBelow && HIDE[column.hideBelow],
|
|
343
|
+
column.sticky && "bg-background sticky left-0 z-20",
|
|
344
|
+
column.headClass ?? column.class,
|
|
345
|
+
)}
|
|
96
346
|
style={column.width ? `width: ${column.width}` : undefined}
|
|
347
|
+
aria-sort={ariaSort(column)}
|
|
97
348
|
>
|
|
98
|
-
{column.
|
|
349
|
+
{#if column.sortable}
|
|
350
|
+
{@const dir = sort?.key === column.key ? sort.direction : null}
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
class={cn(
|
|
354
|
+
"t-press hover:bg-muted/30 focus-visible:ring-ring flex h-10 w-full items-center gap-1 px-2 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-inset",
|
|
355
|
+
column.numeric ? "justify-end" : "justify-start",
|
|
356
|
+
)}
|
|
357
|
+
onclick={() => (sort = toggleSort(sort, column.key as keyof Row & string))}
|
|
358
|
+
>
|
|
359
|
+
{#if head}{@render head(column)}{:else}{column.label}{/if}
|
|
360
|
+
{#if dir === "asc"}
|
|
361
|
+
<CaretUp class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
|
|
362
|
+
{:else if dir === "desc"}
|
|
363
|
+
<CaretDown class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
|
|
364
|
+
{:else}
|
|
365
|
+
<CaretUpDown class="text-muted-foreground/75 size-3.5" aria-hidden="true" />
|
|
366
|
+
{/if}
|
|
367
|
+
</button>
|
|
368
|
+
{:else if head}
|
|
369
|
+
{@render head(column)}
|
|
370
|
+
{:else}
|
|
371
|
+
{column.label}
|
|
372
|
+
{/if}
|
|
99
373
|
</Table.Head>
|
|
100
374
|
{/each}
|
|
375
|
+
{#if rowHref}
|
|
376
|
+
<th class="w-0 p-0"><span class="sr-only">Open</span></th>
|
|
377
|
+
{/if}
|
|
101
378
|
</Table.Row>
|
|
102
379
|
</Table.Header>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
380
|
+
{#if loading}
|
|
381
|
+
<TableSkeleton rows={skeletonRows} columns={skeletonColumns} />
|
|
382
|
+
{:else}
|
|
383
|
+
{#if group}
|
|
384
|
+
{#each entries as entry (entry.id)}
|
|
385
|
+
{@const folded = entry.key !== null && collapsible && collapsed.has(entry.key)}
|
|
386
|
+
{@const tableGroup = { key: entry.key ?? "", rows: entry.rows }}
|
|
387
|
+
<Table.Body>
|
|
388
|
+
{#if entry.key !== null}
|
|
389
|
+
<Table.Row
|
|
390
|
+
class="bg-muted/40 hover:bg-muted/40 hover:translate-y-0 hover:shadow-none"
|
|
391
|
+
>
|
|
392
|
+
<Table.Head
|
|
393
|
+
colspan={span + (rowHref ? 1 : 0)}
|
|
394
|
+
scope="colgroup"
|
|
395
|
+
class="text-foreground h-10 p-0"
|
|
396
|
+
>
|
|
397
|
+
{#if collapsible}
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
aria-expanded={!folded}
|
|
401
|
+
class="t-press focus-visible:ring-ring flex h-10 w-full items-center gap-2 px-2 text-start outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
|
402
|
+
onclick={() => toggleGroup(tableGroup.key)}
|
|
403
|
+
>
|
|
404
|
+
<CaretDown
|
|
405
|
+
class={cn(
|
|
406
|
+
"size-3.5 shrink-0 transition-transform duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] motion-reduce:transition-none",
|
|
407
|
+
folded && "-rotate-90",
|
|
408
|
+
)}
|
|
409
|
+
aria-hidden="true"
|
|
410
|
+
/>
|
|
411
|
+
{#if groupHeader}{@render groupHeader(tableGroup)}{:else}{tableGroup.key}{/if}
|
|
412
|
+
</button>
|
|
413
|
+
{:else}
|
|
414
|
+
<span class="flex h-10 items-center px-2">
|
|
415
|
+
{#if groupHeader}{@render groupHeader(tableGroup)}{:else}{tableGroup.key}{/if}
|
|
416
|
+
</span>
|
|
417
|
+
{/if}
|
|
418
|
+
</Table.Head>
|
|
419
|
+
</Table.Row>
|
|
420
|
+
{/if}
|
|
421
|
+
{#if !folded}
|
|
422
|
+
{#each entry.rows as row (rowKey(row))}
|
|
423
|
+
{@render rowOrMenu(row)}
|
|
424
|
+
{/each}
|
|
425
|
+
{/if}
|
|
426
|
+
</Table.Body>
|
|
427
|
+
{/each}
|
|
428
|
+
{:else}
|
|
429
|
+
<Table.Body>
|
|
430
|
+
{#each rows as row (rowKey(row))}
|
|
431
|
+
{@render rowOrMenu(row)}
|
|
432
|
+
{/each}
|
|
433
|
+
{#if rows.length === 0 && empty}
|
|
434
|
+
<Table.Row class="hover:bg-transparent hover:translate-y-0 hover:shadow-none">
|
|
435
|
+
<Table.Cell colspan={span + (rowHref ? 1 : 0)} class="whitespace-normal"
|
|
436
|
+
>{@render empty()}</Table.Cell
|
|
437
|
+
>
|
|
438
|
+
</Table.Row>
|
|
439
|
+
{/if}
|
|
440
|
+
</Table.Body>
|
|
441
|
+
{/if}
|
|
442
|
+
{#if trailing}
|
|
443
|
+
<tbody>
|
|
444
|
+
<tr><td colspan={span + (rowHref ? 1 : 0)} class="p-0">{@render trailing()}</td></tr>
|
|
445
|
+
</tbody>
|
|
446
|
+
{/if}
|
|
447
|
+
{/if}
|
|
110
448
|
{#if footer}
|
|
111
449
|
<Table.Footer>
|
|
112
450
|
<Table.Row>
|
|
451
|
+
{#if selectable}<Table.Cell></Table.Cell>{/if}
|
|
113
452
|
{@render body(footer)}
|
|
453
|
+
{#if rowHref}<td></td>{/if}
|
|
114
454
|
</Table.Row>
|
|
115
455
|
</Table.Footer>
|
|
116
456
|
{/if}
|
|
@@ -57,3 +57,104 @@ describe("TableView", () => {
|
|
|
57
57
|
done();
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
describe("TableView extensions", () => {
|
|
62
|
+
const sortable = [
|
|
63
|
+
{ key: "name", label: "Name", sortable: true },
|
|
64
|
+
{ key: "total", label: "Total", numeric: true, sortable: true },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
it("sorts from a head button and sets aria-sort", () => {
|
|
68
|
+
const { target, done } = render({ columns: sortable, sort: { key: "name", direction: "asc" } });
|
|
69
|
+
const heads = target.querySelectorAll("thead th");
|
|
70
|
+
expect(heads[0].getAttribute("aria-sort")).toBe("ascending");
|
|
71
|
+
expect(heads[1].getAttribute("aria-sort")).toBe("none");
|
|
72
|
+
(heads[1].querySelector("button") as HTMLButtonElement).click();
|
|
73
|
+
flushSync();
|
|
74
|
+
expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("ascending");
|
|
75
|
+
done();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("draws a real link per row", () => {
|
|
79
|
+
const { target, done } = render({ rowHref: (row: Row) => `/lines/${row.id}` });
|
|
80
|
+
const links = [...target.querySelectorAll("tbody a")].map((a) => a.getAttribute("href"));
|
|
81
|
+
expect(links).toEqual(["/lines/a", "/lines/b"]);
|
|
82
|
+
expect(target.querySelector("tbody a")?.getAttribute("aria-label")).toBe("Audit");
|
|
83
|
+
done();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("selects rows and shows select-all as indeterminate", () => {
|
|
87
|
+
const selected = new Set<string>(["a"]);
|
|
88
|
+
const { target, done } = render({ selectable: true, selected });
|
|
89
|
+
const boxes = target.querySelectorAll<HTMLElement>("[role=checkbox]");
|
|
90
|
+
expect(boxes.length).toBe(3);
|
|
91
|
+
expect(boxes[0].getAttribute("aria-checked")).toBe("mixed");
|
|
92
|
+
expect(target.querySelectorAll("tbody tr")[0].getAttribute("data-state")).toBe("selected");
|
|
93
|
+
done();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("puts a header row over each group, spanning every column", () => {
|
|
97
|
+
const { target, done } = render({ group: (row: Row) => (row.id === "a" ? "One" : "Two") });
|
|
98
|
+
const heads = target.querySelectorAll("tbody th[colspan]");
|
|
99
|
+
expect([...heads].map((h) => h.textContent?.trim())).toEqual(["One", "Two"]);
|
|
100
|
+
expect(heads[0].getAttribute("colspan")).toBe("2");
|
|
101
|
+
done();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("swaps rows for skeletons inside the same frame while loading", () => {
|
|
105
|
+
const { target, done } = render({ loading: true, skeletonRows: 3 });
|
|
106
|
+
expect(target.querySelector("table")?.getAttribute("aria-busy")).toBe("true");
|
|
107
|
+
expect(target.querySelectorAll("tbody tr").length).toBe(3);
|
|
108
|
+
expect(target.querySelector("tbody td")?.textContent?.trim()).toBe("");
|
|
109
|
+
done();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("drops the frame when bare", () => {
|
|
113
|
+
const { target, done } = render({ bare: true });
|
|
114
|
+
expect((target.firstElementChild as HTMLElement).className).not.toContain("border");
|
|
115
|
+
done();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("TableView row actions", () => {
|
|
120
|
+
it("opens a row on click and Enter, but leaves a nested button alone", () => {
|
|
121
|
+
const calls: string[] = [];
|
|
122
|
+
const cell = createRawSnippet<[{ text: string }]>((cellProps) => ({
|
|
123
|
+
render: () => `<span><button>${cellProps().text}</button></span>`,
|
|
124
|
+
}));
|
|
125
|
+
const { target, done } = render({ onRowClick: (row: Row) => calls.push(row.id), cell });
|
|
126
|
+
const tr = target.querySelector("tbody tr") as HTMLElement;
|
|
127
|
+
tr.click();
|
|
128
|
+
tr.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
129
|
+
(tr.querySelector("button") as HTMLButtonElement).click();
|
|
130
|
+
expect(calls).toEqual(["a", "a"]);
|
|
131
|
+
done();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("folds a group, hides a column below a breakpoint and shows a sub-row", () => {
|
|
135
|
+
const subRow = createRawSnippet(() => ({ render: () => `<i>detail</i>` }));
|
|
136
|
+
const { target, done } = render({
|
|
137
|
+
group: () => "All",
|
|
138
|
+
collapsible: true,
|
|
139
|
+
subRow,
|
|
140
|
+
expanded: (row: Row) => row.id === "a",
|
|
141
|
+
columns: [
|
|
142
|
+
{ key: "name", label: "Name" },
|
|
143
|
+
{ key: "total", label: "Total", hideBelow: "md" },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
expect(target.querySelector("tbody i")).not.toBeNull();
|
|
147
|
+
expect(target.querySelector("thead th:last-child")?.className).toContain("md:table-cell");
|
|
148
|
+
(target.querySelector("tbody th button") as HTMLButtonElement).click();
|
|
149
|
+
flushSync();
|
|
150
|
+
expect(target.querySelectorAll("tbody tr").length).toBe(1);
|
|
151
|
+
done();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("draws the empty snippet across the columns", () => {
|
|
155
|
+
const empty = createRawSnippet(() => ({ render: () => `<p>Nothing</p>` }));
|
|
156
|
+
const { target, done } = render({ rows: [], empty });
|
|
157
|
+
expect(target.querySelector("tbody td")?.getAttribute("colspan")).toBe("2");
|
|
158
|
+
done();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -11,4 +11,15 @@ export interface TableColumn<Row> {
|
|
|
11
11
|
width?: string;
|
|
12
12
|
/** Draws the column in the muted colour. */
|
|
13
13
|
muted?: boolean;
|
|
14
|
+
/** Click or press Enter on the head to sort. The table sorts nothing itself: bind `sort` and order `rows` with `sortRows`. */
|
|
15
|
+
sortable?: boolean;
|
|
16
|
+
/** Lifts the column's cells above the row link, so a button or a link inside it takes its own click. */
|
|
17
|
+
interactive?: boolean;
|
|
18
|
+
/** Hidden below this breakpoint, head and cells both. */
|
|
19
|
+
hideBelow?: "sm" | "md" | "lg";
|
|
20
|
+
/** Pins the column to the left edge while the table scrolls sideways. */
|
|
21
|
+
sticky?: boolean;
|
|
22
|
+
/** Extra classes on the column's cells (and its head, unless `headClass` is set). */
|
|
23
|
+
class?: string;
|
|
24
|
+
headClass?: string;
|
|
14
25
|
}
|
package/src/icons/trades.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/*
|
|
18
|
-
* car-garage, hammer / wave-saw-tool, helmet, trowel, grid-dots, leaf, car, crane. Svelte 5 port is ` @tabler/icons-svelte-runes` (runes, not
|
|
18
|
+
* car-garage, hammer / wave-saw-tool, helmet, trowel, grid-dots, leaf, car, crane. Svelte 5 port is ` @tabler/icons-svelte-runes` (runes, not the Svelte 4 ` @tabler/icons-svelte`).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import {
|