@spaethtech/svelte-ui 0.21.1-dev.131.6298c61 → 0.21.1-dev.134.0bfe62d
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/dist/components/DataTable.svelte +125 -3
- package/dist/components/DataTable.svelte.d.ts +15 -0
- package/dist/components/SideBarMenu/SideBarMenu.svelte +88 -28
- package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +19 -4
- package/dist/data/table/grid.svelte.d.ts +19 -1
- package/dist/data/table/grid.svelte.js +68 -1
- package/dist/data/table/local.js +17 -4
- package/dist/data/table/types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/docs/components.md +18 -3
- package/docs/usage.md +11 -0
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
/**
|
|
3
3
|
* DataTable — config-driven, responsive 12-col data table over a DataGrid (svelte-ui/data).
|
|
4
|
-
* Selectable rows, expandable rows, per-row + bulk action menus, sortable headers, paginated footer
|
|
4
|
+
* Selectable rows, expandable rows, per-row + bulk action menus, sortable headers, paginated footer,
|
|
5
|
+
* and optional grouping (rows sectioned under headings via the grid's `groupBy`).
|
|
5
6
|
* Styled on the svelte-ui theme; menus use the Popup/Menu primitives (top layer, flip vs viewport).
|
|
6
7
|
*
|
|
7
8
|
* Compound component: every control is built from our own core components — icon controls and
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
import Select from "./Select.svelte";
|
|
34
35
|
import Checkbox from "./Checkbox.svelte";
|
|
35
36
|
import Menu from "./Menu.svelte";
|
|
37
|
+
import Badge from "./Badge/Badge.svelte";
|
|
36
38
|
import type { Column, GridPlacement, MenuItem, DataGrid } from "../data/table/index.js";
|
|
37
39
|
import type { Size } from "../types/sizes.js";
|
|
38
40
|
import { resolveScalar, type Responsive } from "../types/responsive.js";
|
|
@@ -43,7 +45,9 @@
|
|
|
43
45
|
selectable = false,
|
|
44
46
|
expandable = false,
|
|
45
47
|
reorderable = false,
|
|
48
|
+
collapsibleGroups = false,
|
|
46
49
|
expanded,
|
|
50
|
+
groupHeader,
|
|
47
51
|
rowActions,
|
|
48
52
|
bulkActions,
|
|
49
53
|
onReorder,
|
|
@@ -63,7 +67,18 @@
|
|
|
63
67
|
* ends of the visible page — with server pagination, this
|
|
64
68
|
* reorders WITHIN the visible page only. */
|
|
65
69
|
reorderable?: boolean;
|
|
70
|
+
/** When the grid has a `groupBy`, give each group header a collapse chevron (member rows hide
|
|
71
|
+
* while the header stays). View-only — collapsed rows remain in `rows`/selection. Default
|
|
72
|
+
* `false`, so grouping shows static headings and nothing changes for ungrouped tables. */
|
|
73
|
+
collapsibleGroups?: boolean;
|
|
66
74
|
expanded?: Snippet<[T]>;
|
|
75
|
+
/** Override the group header row's content (the grid must have a `groupBy`). Receives the group's
|
|
76
|
+
* `key`/`label`, the per-page `count`, whether it's `collapsed`, and a `toggle()` to collapse it.
|
|
77
|
+
* Omit for the default (label + count badge, plus a chevron when `collapsibleGroups`). Build it
|
|
78
|
+
* from library components (Composition Standard) — no raw `<button>`. */
|
|
79
|
+
groupHeader?: Snippet<
|
|
80
|
+
[{ key: string; label: string; count: number; collapsed: boolean; toggle: () => void }]
|
|
81
|
+
>;
|
|
67
82
|
/** Per-row kebab menu. Return `null`/`undefined`/`[]` for a row with no actions — the kebab still
|
|
68
83
|
* renders (columns stay aligned) but is disabled, rather than opening an empty/all-disabled menu. */
|
|
69
84
|
rowActions?: (row: T) => MenuItem[] | null | undefined;
|
|
@@ -210,6 +225,45 @@
|
|
|
210
225
|
}),
|
|
211
226
|
);
|
|
212
227
|
|
|
228
|
+
// ── grouping (section the current page) ──────────────────────────────────
|
|
229
|
+
// Model A: we group only the rows we're handed. The grid orders a column-id groupBy group-first
|
|
230
|
+
// (so members are contiguous); a group that straddles a page edge simply repeats its header on the
|
|
231
|
+
// next page. Counts are per-page.
|
|
232
|
+
const grouped = $derived(grid.grouped);
|
|
233
|
+
type GroupMeta = { key: string; label: string; count: number };
|
|
234
|
+
const groupMetas = $derived.by(() => {
|
|
235
|
+
const m = new Map<string, GroupMeta>();
|
|
236
|
+
if (!grouped) return m;
|
|
237
|
+
for (const r of rows) {
|
|
238
|
+
const g = grid.groupOf(r);
|
|
239
|
+
if (!g) continue;
|
|
240
|
+
const ex = m.get(g.key);
|
|
241
|
+
if (ex) ex.count++;
|
|
242
|
+
else m.set(g.key, { key: g.key, label: g.label, count: 1 });
|
|
243
|
+
}
|
|
244
|
+
return m;
|
|
245
|
+
});
|
|
246
|
+
type RowItem = { kind: "row"; row: T; i: number };
|
|
247
|
+
type GroupItem = { kind: "group" } & GroupMeta;
|
|
248
|
+
// Flatten the page into render items: a group header wherever the group key changes, then its
|
|
249
|
+
// rows (skipped while the group is collapsed). Ungrouped → just the rows, unchanged.
|
|
250
|
+
const renderList = $derived.by<(RowItem | GroupItem)[]>(() => {
|
|
251
|
+
if (!grouped) return rows.map((row, i) => ({ kind: "row", row, i }) as RowItem);
|
|
252
|
+
const out: (RowItem | GroupItem)[] = [];
|
|
253
|
+
let prev: string | null = null;
|
|
254
|
+
rows.forEach((row, i) => {
|
|
255
|
+
const g = grid.groupOf(row);
|
|
256
|
+
const key = g ? g.key : "";
|
|
257
|
+
if (key !== prev) {
|
|
258
|
+
out.push({ kind: "group", key, label: g?.label ?? key, count: groupMetas.get(key)?.count ?? 0 });
|
|
259
|
+
prev = key;
|
|
260
|
+
}
|
|
261
|
+
if (collapsibleGroups && grid.isGroupCollapsed(key)) return; // header stays, members hidden
|
|
262
|
+
out.push({ kind: "row", row, i });
|
|
263
|
+
});
|
|
264
|
+
return out;
|
|
265
|
+
});
|
|
266
|
+
|
|
213
267
|
// Invisible icon-slot sizing for the empty-state spacer. Sized to
|
|
214
268
|
// heightVar via inline style at each use site (kept as a snippet
|
|
215
269
|
// of class + style below).
|
|
@@ -382,8 +436,75 @@
|
|
|
382
436
|
></div>{/if}
|
|
383
437
|
</div>
|
|
384
438
|
{:else}
|
|
385
|
-
{#each
|
|
386
|
-
{
|
|
439
|
+
{#each renderList as item (item.kind === "group" ? `g:${item.key}` : grid.getRowId(item.row))}
|
|
440
|
+
{#if item.kind === "group"}
|
|
441
|
+
{@const collapsed = grid.isGroupCollapsed(item.key)}
|
|
442
|
+
<!-- Full-bleed group heading row. role="row" + a single full-width cell (the table uses
|
|
443
|
+
role-on-div semantics without colspan, so this mirrors the empty-state row). The surface
|
|
444
|
+
fill distinguishes it from data rows in every variant, ghost included. When collapsible,
|
|
445
|
+
the WHOLE row is the toggle (mouse + keyboard) and the chevron sits in the tail column,
|
|
446
|
+
aligned with the per-row kebab / bulk-actions button. -->
|
|
447
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
448
|
+
<div
|
|
449
|
+
role="row"
|
|
450
|
+
class="flex items-center {cellGap} {cellPad} [background-color:var(--ui-color-surface)] {collapsibleGroups
|
|
451
|
+
? 'cursor-pointer select-none hover:[background-color:var(--ui-color-hover)]'
|
|
452
|
+
: ''}"
|
|
453
|
+
style="min-height: {heightVar}"
|
|
454
|
+
tabindex={collapsibleGroups ? 0 : undefined}
|
|
455
|
+
aria-label={collapsibleGroups
|
|
456
|
+
? `${item.label} ${collapsed ? i18n.messages.expand : i18n.messages.collapse}`
|
|
457
|
+
: undefined}
|
|
458
|
+
onclick={collapsibleGroups ? () => grid.toggleGroup(item.key) : undefined}
|
|
459
|
+
onkeydown={collapsibleGroups
|
|
460
|
+
? (e) => {
|
|
461
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
grid.toggleGroup(item.key);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
: undefined}
|
|
467
|
+
>
|
|
468
|
+
<div role="cell" class="flex min-w-0 grow items-center gap-2 font-semibold">
|
|
469
|
+
{#if groupHeader}
|
|
470
|
+
{@render groupHeader({
|
|
471
|
+
key: item.key,
|
|
472
|
+
label: item.label,
|
|
473
|
+
count: item.count,
|
|
474
|
+
collapsed,
|
|
475
|
+
toggle: () => grid.toggleGroup(item.key),
|
|
476
|
+
})}
|
|
477
|
+
{:else}
|
|
478
|
+
<span class="truncate">{item.label}</span>
|
|
479
|
+
{/if}
|
|
480
|
+
</div>
|
|
481
|
+
<!-- Right cluster: the count badge sits immediately left of the collapse chevron. The
|
|
482
|
+
chevron is the SAME inert ghost Button the per-row kebab / bulk control uses (identical
|
|
483
|
+
square box + centred icon), so it lines up exactly in that column; it's aria-hidden +
|
|
484
|
+
tabindex -1 + pointer-events:none, so the WHOLE row keeps owning the click/keyboard
|
|
485
|
+
toggle and this is not a nested interactive control. -->
|
|
486
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
487
|
+
{#if !groupHeader}
|
|
488
|
+
<Badge text={String(item.count)} {size} />
|
|
489
|
+
{/if}
|
|
490
|
+
{#if collapsibleGroups}
|
|
491
|
+
<Button
|
|
492
|
+
variant="ghost"
|
|
493
|
+
{size}
|
|
494
|
+
tabindex={-1}
|
|
495
|
+
aria-hidden="true"
|
|
496
|
+
class="pointer-events-none"
|
|
497
|
+
>
|
|
498
|
+
{#snippet icon()}{#if collapsed}<IconChevronRight />{:else}<IconChevronDown
|
|
499
|
+
/>{/if}{/snippet}
|
|
500
|
+
</Button>
|
|
501
|
+
{/if}
|
|
502
|
+
</div>
|
|
503
|
+
</div>
|
|
504
|
+
{:else}
|
|
505
|
+
{@const row = item.row}
|
|
506
|
+
{@const rowIndex = item.i}
|
|
507
|
+
{@const id = grid.getRowId(row)}
|
|
387
508
|
<!-- Row + its expand panel form one hover group, so they always share the SAME background
|
|
388
509
|
(transparent by default; both take the hover tint together). -->
|
|
389
510
|
<div class="group">
|
|
@@ -488,6 +609,7 @@
|
|
|
488
609
|
</div>
|
|
489
610
|
{/if}
|
|
490
611
|
</div>
|
|
612
|
+
{/if}
|
|
491
613
|
{/each}
|
|
492
614
|
{/if}
|
|
493
615
|
</div>
|
|
@@ -16,7 +16,22 @@ declare function $$render<T>(): {
|
|
|
16
16
|
* ends of the visible page — with server pagination, this
|
|
17
17
|
* reorders WITHIN the visible page only. */
|
|
18
18
|
reorderable?: boolean;
|
|
19
|
+
/** When the grid has a `groupBy`, give each group header a collapse chevron (member rows hide
|
|
20
|
+
* while the header stays). View-only — collapsed rows remain in `rows`/selection. Default
|
|
21
|
+
* `false`, so grouping shows static headings and nothing changes for ungrouped tables. */
|
|
22
|
+
collapsibleGroups?: boolean;
|
|
19
23
|
expanded?: Snippet<[T]>;
|
|
24
|
+
/** Override the group header row's content (the grid must have a `groupBy`). Receives the group's
|
|
25
|
+
* `key`/`label`, the per-page `count`, whether it's `collapsed`, and a `toggle()` to collapse it.
|
|
26
|
+
* Omit for the default (label + count badge, plus a chevron when `collapsibleGroups`). Build it
|
|
27
|
+
* from library components (Composition Standard) — no raw `<button>`. */
|
|
28
|
+
groupHeader?: Snippet<[{
|
|
29
|
+
key: string;
|
|
30
|
+
label: string;
|
|
31
|
+
count: number;
|
|
32
|
+
collapsed: boolean;
|
|
33
|
+
toggle: () => void;
|
|
34
|
+
}]>;
|
|
20
35
|
/** Per-row kebab menu. Return `null`/`undefined`/`[]` for a row with no actions — the kebab still
|
|
21
36
|
* renders (columns stay aligned) but is disabled, rather than opening an empty/all-disabled menu. */
|
|
22
37
|
rowActions?: (row: T) => MenuItem[] | null | undefined;
|
|
@@ -95,8 +95,9 @@
|
|
|
95
95
|
link?: string;
|
|
96
96
|
badge?: SideBarMenuBadge;
|
|
97
97
|
/** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
|
|
98
|
-
* cascades into a further popout (or steps deeper in `stepped` mode).
|
|
99
|
-
|
|
98
|
+
* cascades into a further popout (or steps deeper in `stepped` mode). May include
|
|
99
|
+
* `{ divider: true }` entries to draw a separator between children. */
|
|
100
|
+
children?: SideBarMenuEntry[];
|
|
100
101
|
/** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
|
|
101
102
|
* row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
|
|
102
103
|
onclick?: (e: MouseEvent) => void;
|
|
@@ -114,6 +115,20 @@
|
|
|
114
115
|
* the default `currentPath` segment-boundary match against `link`. */
|
|
115
116
|
activeMatch?: SideBarMenuActiveRule;
|
|
116
117
|
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
|
|
121
|
+
* full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
|
|
122
|
+
* navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
|
|
123
|
+
* Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
|
|
124
|
+
* rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
|
|
125
|
+
* expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
|
|
126
|
+
* still falls through to the visible "malformed item" row rather than silently becoming a separator.
|
|
127
|
+
*/
|
|
128
|
+
export type SideBarMenuDivider = { divider: true };
|
|
129
|
+
|
|
130
|
+
/** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
|
|
131
|
+
export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
|
|
117
132
|
</script>
|
|
118
133
|
|
|
119
134
|
<script lang="ts">
|
|
@@ -159,8 +174,8 @@
|
|
|
159
174
|
class: additionalClass = "",
|
|
160
175
|
...rest
|
|
161
176
|
}: {
|
|
162
|
-
items?:
|
|
163
|
-
bottomItems?:
|
|
177
|
+
items?: SideBarMenuEntry[];
|
|
178
|
+
bottomItems?: SideBarMenuEntry[];
|
|
164
179
|
currentPath: string;
|
|
165
180
|
size?: Size;
|
|
166
181
|
/**
|
|
@@ -492,7 +507,34 @@
|
|
|
492
507
|
// reserving group that lack an icon still get an empty box so everything aligns. The main rail counts
|
|
493
508
|
// `items` + `bottomItems` as ONE group (so top/bottom align); each popout / stepped sub-view is its
|
|
494
509
|
// own group.
|
|
495
|
-
|
|
510
|
+
// ── Dividers ────────────────────────────────────────────────────
|
|
511
|
+
// A list entry is either a real item or a `{ divider: true }` separator. Dividers are not navigable
|
|
512
|
+
// rows — they render as a 1px full-bleed rule BETWEEN two real rows (see `renderRows`).
|
|
513
|
+
const isDivider = (e: SideBarMenuEntry): e is SideBarMenuDivider =>
|
|
514
|
+
typeof e === "object" && e !== null && (e as SideBarMenuDivider).divider === true;
|
|
515
|
+
const itemsOnly = (list: SideBarMenuEntry[] = []): SideBarMenuItem[] =>
|
|
516
|
+
list.filter((e): e is SideBarMenuItem => !isDivider(e));
|
|
517
|
+
|
|
518
|
+
// Normalize a list into the rows to render, each tagged with whether a divider precedes it. Leading,
|
|
519
|
+
// trailing, and consecutive dividers collapse — `dividerBefore` is set only when a divider sits
|
|
520
|
+
// between two real rows. `index` is the entry's ORIGINAL position in the list, so open-path ids
|
|
521
|
+
// (`top:3`, `id>5`) stay stable and unique no matter how many dividers precede a row.
|
|
522
|
+
type RenderRow = { item: SideBarMenuItem; index: number; dividerBefore: boolean };
|
|
523
|
+
function renderRows(list: SideBarMenuEntry[] = []): RenderRow[] {
|
|
524
|
+
const out: RenderRow[] = [];
|
|
525
|
+
let pending = false;
|
|
526
|
+
list.forEach((e, index) => {
|
|
527
|
+
if (isDivider(e)) {
|
|
528
|
+
if (out.length > 0) pending = true; // drop leading; collapse runs of dividers
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
out.push({ item: e, index, dividerBefore: pending });
|
|
532
|
+
pending = false;
|
|
533
|
+
});
|
|
534
|
+
return out; // a trailing divider is never consumed → dropped
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const anyIcon = (list: SideBarMenuEntry[] = []) => itemsOnly(list).some((i) => i.icon || i.badge);
|
|
496
538
|
const railReserve = $derived(anyIcon(items) || anyIcon(bottomItems));
|
|
497
539
|
|
|
498
540
|
// ── Mobile drawer state ─────────────────────────────────────────
|
|
@@ -605,7 +647,8 @@
|
|
|
605
647
|
} else if (item.link && pathMatches(item.link, currentPath)) {
|
|
606
648
|
return true;
|
|
607
649
|
}
|
|
608
|
-
|
|
650
|
+
const kids = itemsOnly(item.children);
|
|
651
|
+
if (kids.length) return kids.some(isItemActive);
|
|
609
652
|
return false;
|
|
610
653
|
}
|
|
611
654
|
// A row's OWN active state (drives `aria-current`) — its own rule/link only, never descendants.
|
|
@@ -919,7 +962,7 @@
|
|
|
919
962
|
{@const active = isItemActive(item)}
|
|
920
963
|
{@const ownActive = isItemOwnActive(item)}
|
|
921
964
|
{@const reserveIcon = opts.reserveIcon ?? true}
|
|
922
|
-
{@const hasChildren = (item.children
|
|
965
|
+
{@const hasChildren = itemsOnly(item.children).length > 0}
|
|
923
966
|
{@const isParent = hasChildren || !!item.popout}
|
|
924
967
|
{@const hasLink = !!item.link}
|
|
925
968
|
{@const isOpen = !!opts.id && isOnPath(opts.id)}
|
|
@@ -1028,7 +1071,7 @@
|
|
|
1028
1071
|
depth: number,
|
|
1029
1072
|
)}
|
|
1030
1073
|
{@render row(item, { id, isPopoutChild: depth > 0, reserveIcon })}
|
|
1031
|
-
{#if ((
|
|
1074
|
+
{#if (itemsOnly(item.children).length > 0 || !!item.popout) && !isStepped}
|
|
1032
1075
|
{@const top = depth === 0}
|
|
1033
1076
|
{@const geom = top
|
|
1034
1077
|
? geometryFor(id, group)
|
|
@@ -1085,9 +1128,9 @@
|
|
|
1085
1128
|
{#snippet popoutPanel(item: SideBarMenuItem, id: ItemId, group: "top" | "bottom", maxHeight: string)}
|
|
1086
1129
|
{@const popoutId = popoutDomId(id)}
|
|
1087
1130
|
{@const isOpen = isOnPath(id)}
|
|
1088
|
-
{@const
|
|
1089
|
-
{@const win = popoutWindow(id,
|
|
1090
|
-
{@const childReserve = anyIcon(
|
|
1131
|
+
{@const rows = renderRows(item.children)}
|
|
1132
|
+
{@const win = popoutWindow(id, rows.length)}
|
|
1133
|
+
{@const childReserve = anyIcon(item.children)}
|
|
1091
1134
|
{@const rowsShown = (win.up ? 1 : 0) + (win.end - win.start) + (win.down ? 1 : 0)}
|
|
1092
1135
|
{@const bd = popoutBorder(id, group, !id.includes(">"), rowsShown)}
|
|
1093
1136
|
<!-- Nav submenu popout — a list of links (NOT role="menu"); Tab navigates, per the disclosure-nav
|
|
@@ -1106,7 +1149,7 @@
|
|
|
1106
1149
|
: (e) => {
|
|
1107
1150
|
if (win.up || win.down) {
|
|
1108
1151
|
e.preventDefault();
|
|
1109
|
-
stepPopout(id,
|
|
1152
|
+
stepPopout(id, rows.length, Math.sign(e.deltaY) * 3);
|
|
1110
1153
|
}
|
|
1111
1154
|
}}
|
|
1112
1155
|
onkeydown={item.popout
|
|
@@ -1114,10 +1157,10 @@
|
|
|
1114
1157
|
: (e) => {
|
|
1115
1158
|
if (e.key === "ArrowDown") {
|
|
1116
1159
|
e.preventDefault();
|
|
1117
|
-
stepPopout(id,
|
|
1160
|
+
stepPopout(id, rows.length, 1);
|
|
1118
1161
|
} else if (e.key === "ArrowUp") {
|
|
1119
1162
|
e.preventDefault();
|
|
1120
|
-
stepPopout(id,
|
|
1163
|
+
stepPopout(id, rows.length, -1);
|
|
1121
1164
|
}
|
|
1122
1165
|
}}
|
|
1123
1166
|
>
|
|
@@ -1129,15 +1172,20 @@
|
|
|
1129
1172
|
{@render item.popout()}
|
|
1130
1173
|
</div>
|
|
1131
1174
|
{:else}
|
|
1132
|
-
{#if win.up}{@render arrowRow(id,
|
|
1175
|
+
{#if win.up}{@render arrowRow(id, rows.length, -1)}{/if}
|
|
1133
1176
|
<ul class="flex flex-col">
|
|
1134
|
-
{#each
|
|
1177
|
+
{#each rows.slice(win.start, win.end) as r, i (r.index)}
|
|
1178
|
+
<!-- A divider renders only BETWEEN real rows: suppress it at the window's top edge (i===0),
|
|
1179
|
+
where there's nothing above it and it would collide with the panel border. -->
|
|
1180
|
+
{#if r.dividerBefore && i > 0}
|
|
1181
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1182
|
+
{/if}
|
|
1135
1183
|
<li class="list-none">
|
|
1136
|
-
{@render menuNode(
|
|
1184
|
+
{@render menuNode(r.item, `${id}>${r.index}`, group, childReserve, 1)}
|
|
1137
1185
|
</li>
|
|
1138
1186
|
{/each}
|
|
1139
1187
|
</ul>
|
|
1140
|
-
{#if win.down}{@render arrowRow(id,
|
|
1188
|
+
{#if win.down}{@render arrowRow(id, rows.length, 1)}{/if}
|
|
1141
1189
|
{/if}
|
|
1142
1190
|
</div>
|
|
1143
1191
|
{/snippet}
|
|
@@ -1203,20 +1251,29 @@
|
|
|
1203
1251
|
return here — there is no in-drawer back row by design so
|
|
1204
1252
|
the back affordance stays in a single, consistent spot. -->
|
|
1205
1253
|
{@const currentParent = stepPath[stepPath.length - 1]}
|
|
1206
|
-
{@const
|
|
1254
|
+
{@const stepRows = renderRows(currentParent.children)}
|
|
1255
|
+
{@const stepReserve = anyIcon(currentParent.children)}
|
|
1207
1256
|
<ul class="flex flex-col {scrollList}">
|
|
1208
|
-
{#each
|
|
1257
|
+
{#each stepRows as r, i (r.index)}
|
|
1258
|
+
{#if r.dividerBefore && i > 0}
|
|
1259
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1260
|
+
{/if}
|
|
1209
1261
|
<li class="list-none">
|
|
1210
|
-
{@render row(
|
|
1262
|
+
{@render row(r.item, { id: `step:${r.index}`, isPopoutChild: true, reserveIcon: stepReserve })}
|
|
1211
1263
|
</li>
|
|
1212
1264
|
{/each}
|
|
1213
1265
|
</ul>
|
|
1214
1266
|
{:else}
|
|
1215
|
-
{
|
|
1267
|
+
{@const topRows = renderRows(items)}
|
|
1268
|
+
{@const bottomRows = renderRows(bottomItems)}
|
|
1269
|
+
{#if topRows.length > 0}
|
|
1216
1270
|
<ul class="flex flex-col {scrollList}">
|
|
1217
|
-
{#each
|
|
1271
|
+
{#each topRows as r, i (r.index)}
|
|
1272
|
+
{#if r.dividerBefore && i > 0}
|
|
1273
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1274
|
+
{/if}
|
|
1218
1275
|
<li class="list-none">
|
|
1219
|
-
{@render menuNode(item, `top:${
|
|
1276
|
+
{@render menuNode(r.item, `top:${r.index}`, "top", railReserve, 0)}
|
|
1220
1277
|
</li>
|
|
1221
1278
|
{/each}
|
|
1222
1279
|
</ul>
|
|
@@ -1224,15 +1281,18 @@
|
|
|
1224
1281
|
|
|
1225
1282
|
<!-- With items present, the scrollable top list (flex-1) already fills the gap; only add a spacer
|
|
1226
1283
|
to push the bottom cluster down when there are no top items. -->
|
|
1227
|
-
{#if
|
|
1284
|
+
{#if topRows.length === 0 && bottomRows.length > 0}
|
|
1228
1285
|
<div class="flex-1"></div>
|
|
1229
1286
|
{/if}
|
|
1230
1287
|
|
|
1231
|
-
{#if
|
|
1288
|
+
{#if bottomRows.length > 0}
|
|
1232
1289
|
<ul class="flex flex-col shrink-0">
|
|
1233
|
-
{#each
|
|
1290
|
+
{#each bottomRows as r, i (r.index)}
|
|
1291
|
+
{#if r.dividerBefore && i > 0}
|
|
1292
|
+
<li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
|
|
1293
|
+
{/if}
|
|
1234
1294
|
<li class="list-none">
|
|
1235
|
-
{@render menuNode(item, `bottom:${
|
|
1295
|
+
{@render menuNode(r.item, `bottom:${r.index}`, "bottom", railReserve, 0)}
|
|
1236
1296
|
</li>
|
|
1237
1297
|
{/each}
|
|
1238
1298
|
</ul>
|
|
@@ -75,8 +75,9 @@ export type SideBarMenuItem = {
|
|
|
75
75
|
link?: string;
|
|
76
76
|
badge?: SideBarMenuBadge;
|
|
77
77
|
/** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
|
|
78
|
-
* cascades into a further popout (or steps deeper in `stepped` mode).
|
|
79
|
-
|
|
78
|
+
* cascades into a further popout (or steps deeper in `stepped` mode). May include
|
|
79
|
+
* `{ divider: true }` entries to draw a separator between children. */
|
|
80
|
+
children?: SideBarMenuEntry[];
|
|
80
81
|
/** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
|
|
81
82
|
* row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
|
|
82
83
|
onclick?: (e: MouseEvent) => void;
|
|
@@ -94,14 +95,28 @@ export type SideBarMenuItem = {
|
|
|
94
95
|
* the default `currentPath` segment-boundary match against `link`. */
|
|
95
96
|
activeMatch?: SideBarMenuActiveRule;
|
|
96
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
|
|
100
|
+
* full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
|
|
101
|
+
* navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
|
|
102
|
+
* Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
|
|
103
|
+
* rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
|
|
104
|
+
* expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
|
|
105
|
+
* still falls through to the visible "malformed item" row rather than silently becoming a separator.
|
|
106
|
+
*/
|
|
107
|
+
export type SideBarMenuDivider = {
|
|
108
|
+
divider: true;
|
|
109
|
+
};
|
|
110
|
+
/** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
|
|
111
|
+
export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
|
|
97
112
|
import type { HTMLAttributes } from "svelte/elements";
|
|
98
113
|
import type { Size } from "../../types/sizes.js";
|
|
99
114
|
import { type Variant } from "../../types/variants.js";
|
|
100
115
|
import { type Breakpoint } from "../../types/breakpoints.js";
|
|
101
116
|
type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
|
|
102
117
|
type $$ComponentProps = {
|
|
103
|
-
items?:
|
|
104
|
-
bottomItems?:
|
|
118
|
+
items?: SideBarMenuEntry[];
|
|
119
|
+
bottomItems?: SideBarMenuEntry[];
|
|
105
120
|
currentPath: string;
|
|
106
121
|
size?: Size;
|
|
107
122
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SvelteSet } from "svelte/reactivity";
|
|
2
2
|
import type { DataSet, QueryColumn } from "../dataset";
|
|
3
|
-
import type { Column, FetchFn, GridState, Sort } from "./types";
|
|
3
|
+
import type { Column, FetchFn, GridState, GroupBy, GroupKey, Sort } from "./types";
|
|
4
4
|
export type GridOptions<T> = {
|
|
5
5
|
columns: Column<T>[];
|
|
6
6
|
fetch: FetchFn<T>;
|
|
@@ -12,6 +12,9 @@ export type GridOptions<T> = {
|
|
|
12
12
|
/** Per-row selection lock. Return `false` to make a row non-selectable: its checkbox is disabled,
|
|
13
13
|
* it's excluded from select-all, and `toggleRow`/`selected` never include it. Default: all selectable. */
|
|
14
14
|
selectableRow?: (row: T) => boolean;
|
|
15
|
+
/** Section rows under group headings. A column id (ordered group-first automatically) or a function
|
|
16
|
+
* returning the group identity. See {@link GroupBy}. Undefined → no grouping. */
|
|
17
|
+
groupBy?: GroupBy<T>;
|
|
15
18
|
};
|
|
16
19
|
/**
|
|
17
20
|
* Shared, reactive controller for a data table. Implements {@link DataSet} (so any `<Query>` can
|
|
@@ -30,11 +33,15 @@ export declare class DataGrid<T> implements DataSet {
|
|
|
30
33
|
sort: Sort | null;
|
|
31
34
|
page: number;
|
|
32
35
|
perPage: number;
|
|
36
|
+
/** Active grouping (reactive, so toggling it live re-sections the table). See {@link GroupBy}. */
|
|
37
|
+
groupBy: GroupBy<T> | undefined;
|
|
33
38
|
rows: T[];
|
|
34
39
|
total: number;
|
|
35
40
|
loading: boolean;
|
|
36
41
|
readonly selected: SvelteSet<string>;
|
|
37
42
|
readonly expanded: SvelteSet<string>;
|
|
43
|
+
/** Keys of groups the user has collapsed (view-only; member rows stay in `rows`/selection). */
|
|
44
|
+
readonly collapsedGroups: SvelteSet<string>;
|
|
38
45
|
constructor(opts: GridOptions<T>);
|
|
39
46
|
get queryColumns(): QueryColumn[];
|
|
40
47
|
get dirty(): boolean;
|
|
@@ -61,5 +68,16 @@ export declare class DataGrid<T> implements DataSet {
|
|
|
61
68
|
toggleExpand(id: string): void;
|
|
62
69
|
get allExpanded(): boolean;
|
|
63
70
|
toggleExpandAll(): void;
|
|
71
|
+
get grouped(): boolean;
|
|
72
|
+
/** Resolve a row's group identity (null when not grouping). A column-id groupBy reads `row[id]`
|
|
73
|
+
* (stringified); a function groupBy returns the identity (a bare string becomes `{key,label}`). */
|
|
74
|
+
groupOf(row: T): GroupKey | null;
|
|
75
|
+
/** Distinct group keys present on the current page, in first-seen order. */
|
|
76
|
+
get pageGroupKeys(): string[];
|
|
77
|
+
isGroupCollapsed(key: string): boolean;
|
|
78
|
+
toggleGroup(key: string): void;
|
|
79
|
+
get allGroupsCollapsed(): boolean;
|
|
80
|
+
/** Collapse every group on the page, or expand them all if all are already collapsed. */
|
|
81
|
+
toggleAllGroups(): void;
|
|
64
82
|
}
|
|
65
83
|
export declare function createGrid<T>(opts: GridOptions<T>): DataGrid<T>;
|
|
@@ -17,16 +17,21 @@ export class DataGrid {
|
|
|
17
17
|
sort = $state(null);
|
|
18
18
|
page = $state(1);
|
|
19
19
|
perPage = $state(25);
|
|
20
|
+
/** Active grouping (reactive, so toggling it live re-sections the table). See {@link GroupBy}. */
|
|
21
|
+
groupBy = $state(undefined);
|
|
20
22
|
rows = $state([]);
|
|
21
23
|
total = $state(0);
|
|
22
24
|
loading = $state(false);
|
|
23
25
|
selected = new SvelteSet();
|
|
24
26
|
expanded = new SvelteSet();
|
|
27
|
+
/** Keys of groups the user has collapsed (view-only; member rows stay in `rows`/selection). */
|
|
28
|
+
collapsedGroups = new SvelteSet();
|
|
25
29
|
constructor(opts) {
|
|
26
30
|
this.columns = opts.columns;
|
|
27
31
|
this.fetch = opts.fetch;
|
|
28
32
|
this.getRowId = opts.getRowId ?? ((r) => String(r.id));
|
|
29
33
|
this.selectableRow = opts.selectableRow ?? (() => true);
|
|
34
|
+
this.groupBy = opts.groupBy;
|
|
30
35
|
this.perPage = opts.perPage ?? 25;
|
|
31
36
|
this.perPageOptions = opts.perPageOptions ?? [10, 25, 50, 100];
|
|
32
37
|
this.sort = opts.initialSort ?? null;
|
|
@@ -63,7 +68,15 @@ export class DataGrid {
|
|
|
63
68
|
}
|
|
64
69
|
// ---- table view-state ----
|
|
65
70
|
get request() {
|
|
66
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
q: this.applied,
|
|
73
|
+
sort: this.sort,
|
|
74
|
+
page: this.page,
|
|
75
|
+
perPage: this.perPage,
|
|
76
|
+
// Only a column-id groupBy is serializable → emit it so the fetch orders group-first. A
|
|
77
|
+
// function groupBy is client-only (ordering is the consumer's), so `group` stays null.
|
|
78
|
+
group: typeof this.groupBy === "string" ? { key: this.groupBy, dir: "asc" } : null,
|
|
79
|
+
};
|
|
67
80
|
}
|
|
68
81
|
get pageCount() {
|
|
69
82
|
return Math.max(1, Math.ceil(this.total / this.perPage));
|
|
@@ -146,6 +159,60 @@ export class DataGrid {
|
|
|
146
159
|
else
|
|
147
160
|
ids.forEach((id) => this.expanded.add(id));
|
|
148
161
|
}
|
|
162
|
+
// ---- grouping (current page) ----
|
|
163
|
+
get grouped() {
|
|
164
|
+
return this.groupBy != null;
|
|
165
|
+
}
|
|
166
|
+
/** Resolve a row's group identity (null when not grouping). A column-id groupBy reads `row[id]`
|
|
167
|
+
* (stringified); a function groupBy returns the identity (a bare string becomes `{key,label}`). */
|
|
168
|
+
groupOf(row) {
|
|
169
|
+
const g = this.groupBy;
|
|
170
|
+
if (g == null)
|
|
171
|
+
return null;
|
|
172
|
+
if (typeof g === "function") {
|
|
173
|
+
const r = g(row);
|
|
174
|
+
return typeof r === "string" ? { key: r, label: r } : r;
|
|
175
|
+
}
|
|
176
|
+
const v = row[g];
|
|
177
|
+
const s = v == null ? "" : String(v);
|
|
178
|
+
return { key: s, label: s };
|
|
179
|
+
}
|
|
180
|
+
/** Distinct group keys present on the current page, in first-seen order. */
|
|
181
|
+
get pageGroupKeys() {
|
|
182
|
+
if (this.groupBy == null)
|
|
183
|
+
return [];
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const r of this.rows) {
|
|
187
|
+
const g = this.groupOf(r);
|
|
188
|
+
if (g && !seen.has(g.key)) {
|
|
189
|
+
seen.add(g.key);
|
|
190
|
+
out.push(g.key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
isGroupCollapsed(key) {
|
|
196
|
+
return this.collapsedGroups.has(key);
|
|
197
|
+
}
|
|
198
|
+
toggleGroup(key) {
|
|
199
|
+
if (this.collapsedGroups.has(key))
|
|
200
|
+
this.collapsedGroups.delete(key);
|
|
201
|
+
else
|
|
202
|
+
this.collapsedGroups.add(key);
|
|
203
|
+
}
|
|
204
|
+
get allGroupsCollapsed() {
|
|
205
|
+
const ks = this.pageGroupKeys;
|
|
206
|
+
return ks.length > 0 && ks.every((k) => this.collapsedGroups.has(k));
|
|
207
|
+
}
|
|
208
|
+
/** Collapse every group on the page, or expand them all if all are already collapsed. */
|
|
209
|
+
toggleAllGroups() {
|
|
210
|
+
const ks = this.pageGroupKeys;
|
|
211
|
+
if (this.allGroupsCollapsed)
|
|
212
|
+
ks.forEach((k) => this.collapsedGroups.delete(k));
|
|
213
|
+
else
|
|
214
|
+
ks.forEach((k) => this.collapsedGroups.add(k));
|
|
215
|
+
}
|
|
149
216
|
}
|
|
150
217
|
export function createGrid(opts) {
|
|
151
218
|
return new DataGrid(opts);
|
package/dist/data/table/local.js
CHANGED
|
@@ -50,10 +50,23 @@ export function localFetch(source, columns, opts = {}) {
|
|
|
50
50
|
now: opts.now?.(),
|
|
51
51
|
}))
|
|
52
52
|
: all.slice();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
// Group-first ordering: when the grid passes a serializable `group` (a column-id groupBy), sort by
|
|
54
|
+
// the group key PRIMARY so members are contiguous, then by the user's column sort WITHIN each group.
|
|
55
|
+
const gkey = state.group ? sortKeyOf(state.group.key) : null;
|
|
56
|
+
const gdir = state.group?.dir === "desc" ? -1 : 1;
|
|
57
|
+
const skey = state.sort ? sortKeyOf(state.sort.col) : null;
|
|
58
|
+
const sdir = state.sort?.dir === "desc" ? -1 : 1;
|
|
59
|
+
if (gkey || skey) {
|
|
60
|
+
filtered.sort((a, b) => {
|
|
61
|
+
if (gkey) {
|
|
62
|
+
const c = naturalCompare(value(a, gkey), value(b, gkey)) * gdir;
|
|
63
|
+
if (c !== 0)
|
|
64
|
+
return c;
|
|
65
|
+
}
|
|
66
|
+
if (skey && skey !== gkey)
|
|
67
|
+
return naturalCompare(value(a, skey), value(b, skey)) * sdir;
|
|
68
|
+
return 0;
|
|
69
|
+
});
|
|
57
70
|
}
|
|
58
71
|
const total = filtered.length;
|
|
59
72
|
const start = (state.page - 1) * state.perPage;
|
|
@@ -6,6 +6,25 @@ export type Sort = {
|
|
|
6
6
|
col: string;
|
|
7
7
|
dir: SortDir;
|
|
8
8
|
};
|
|
9
|
+
/** A row's group identity under `groupBy`. `key` dedupes rows into a group and drives collapse
|
|
10
|
+
* state; `label` is what the group header shows. */
|
|
11
|
+
export type GroupKey = {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* How `DataTable` sections rows under group headings. Either:
|
|
17
|
+
* - a **column id** — the group value is `row[id]` (stringified), and the grid orders rows by it
|
|
18
|
+
* FIRST (then by the active column sort) so members are contiguous; serializable, so a remote
|
|
19
|
+
* `query()` can honour it via {@link GridState.group}; or
|
|
20
|
+
* - a **function** — returns the group identity directly (or a bare string used as both key + label).
|
|
21
|
+
* Client-only / non-serializable: the view still sections, but YOU must order the rows by group
|
|
22
|
+
* (set a matching sort) — `GridState.group` is not emitted for the function form.
|
|
23
|
+
*
|
|
24
|
+
* Grouping sections the rows of the **current page** (a group spanning a page edge repeats its header
|
|
25
|
+
* on the next page); pair it with a group-key sort + a large/`"all"` perPage for complete groups.
|
|
26
|
+
*/
|
|
27
|
+
export type GroupBy<T> = string | ((row: T) => GroupKey | string);
|
|
9
28
|
/** Our custom breakpoints + Tailwind defaults. `base` = mobile-first (no prefix).
|
|
10
29
|
*
|
|
11
30
|
* Pixel boundaries (defined in theme.css `@theme`):
|
|
@@ -45,6 +64,14 @@ export type GridState = {
|
|
|
45
64
|
sort: Sort | null;
|
|
46
65
|
page: number;
|
|
47
66
|
perPage: number;
|
|
67
|
+
/** Set when `groupBy` is a **column id**: order rows by this key FIRST (then by `sort`, within each
|
|
68
|
+
* group) so group members are contiguous. Serializable, so a remote `query()` can honour it. `null`
|
|
69
|
+
* when ungrouped or grouping by a function. Optional — pre-existing fetchers that ignore it still
|
|
70
|
+
* work (grouping just needs the rows to already be group-contiguous). */
|
|
71
|
+
group?: {
|
|
72
|
+
key: string;
|
|
73
|
+
dir: SortDir;
|
|
74
|
+
} | null;
|
|
48
75
|
};
|
|
49
76
|
export type FetchResult<T> = {
|
|
50
77
|
rows: T[];
|
package/dist/index.d.ts
CHANGED
|
@@ -76,7 +76,7 @@ export { default as Disclosure2 } from "./components/Disclosure2.svelte";
|
|
|
76
76
|
export { default as Accordion2 } from "./components/Accordion2.svelte";
|
|
77
77
|
export { default as DatePicker2 } from "./components/DatePicker2.svelte";
|
|
78
78
|
export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
79
|
-
export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
79
|
+
export type { SideBarMenuItem, SideBarMenuDivider, SideBarMenuEntry, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
80
80
|
export { Breadcrumbs } from "./components/Breadcrumbs/index.js";
|
|
81
81
|
export type { BreadcrumbItem } from "./components/Breadcrumbs/index.js";
|
|
82
82
|
export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
|
package/docs/components.md
CHANGED
|
@@ -483,10 +483,25 @@ Config-driven, responsive 12-column data table over a `DataGrid`.
|
|
|
483
483
|
|
|
484
484
|
- **Location**: `src/lib/components/DataTable.svelte`
|
|
485
485
|
- **Props**: `grid` (`DataGrid<T>` from `@spaethtech/svelte-ui/data`), `selectable`, `expandable`, `expanded`
|
|
486
|
-
(snippet), `rowActions` (`(row) => MenuItem[] | null`), `bulkActions`
|
|
486
|
+
(snippet), `rowActions` (`(row) => MenuItem[] | null`), `bulkActions`, `collapsibleGroups`, `groupHeader`
|
|
487
|
+
(snippet)
|
|
487
488
|
- **Features**: selectable + expandable rows, sortable headers, per-row & bulk action menus
|
|
488
|
-
(via `Menu`/`Popup`), paginated footer. A **compound component** — all
|
|
489
|
-
`<Button variant="plain" size="sm">` / `<Select size="sm">` / `<Checkbox>`.
|
|
489
|
+
(via `Menu`/`Popup`), paginated footer, optional **grouping** (below). A **compound component** — all
|
|
490
|
+
controls are `<Button variant="plain" size="sm">` / `<Select size="sm">` / `<Checkbox>`.
|
|
491
|
+
- **Grouping (`groupBy`)**: pass **`groupBy: string | (row) => GroupKey | string`** to `createGrid` — a
|
|
492
|
+
**column id** (value `row[id]`, stringified; the grid orders rows **group-first** automatically) or a
|
|
493
|
+
**function** returning the group identity (client-only — order the rows yourself). `DataTable` then
|
|
494
|
+
draws a full-bleed **group-header row** at each group-key change, showing the label + a per-page count
|
|
495
|
+
`Badge`. Set **`collapsibleGroups`** to make the whole header row a toggle (mouse + keyboard) with a
|
|
496
|
+
chevron in the tail column (aligned to the kebab); collapse is **view-only** — member rows stay in
|
|
497
|
+
`grid.rows`/selection (`grid.collapsedGroups`, `toggleGroup(key)`, `toggleAllGroups()`). A
|
|
498
|
+
**`groupHeader`** snippet (`{ key, label, count, collapsed, toggle }`) replaces the default header for
|
|
499
|
+
aggregates/chips (build it from library components). **Model — grouping sections the _current page_**:
|
|
500
|
+
a column-id `groupBy` emits an optional **`group`** field on `GridState` (`{ key, dir }`) that
|
|
501
|
+
`localFetch` applies as the primary sort (user sort applies *within* each group) and a remote `query()`
|
|
502
|
+
honours as `ORDER BY <group>, <sort>`; a group spanning a page edge repeats its header on the next
|
|
503
|
+
page, and counts are per-page. Pair grouping with a group-key sort + a large/`"all"` perPage for
|
|
504
|
+
complete-looking groups. Ungrouped tables are unchanged.
|
|
490
505
|
- **`rowActions` per row**: return `null`/`undefined`/`[]` for a row with no actions — the kebab still
|
|
491
506
|
renders (the tail column stays aligned across rows) but is **disabled**, rather than opening an empty
|
|
492
507
|
or all-greyed menu. Return items to enable it.
|
package/docs/usage.md
CHANGED
|
@@ -1144,16 +1144,27 @@ bar with `Query` over a `DataSet`.
|
|
|
1144
1144
|
// optional: lock specific rows out of selection (disabled checkbox, excluded from select-all,
|
|
1145
1145
|
// never in grid.selected — so bulkActions(selected) never sees them)
|
|
1146
1146
|
selectableRow: (r) => !r.locked,
|
|
1147
|
+
// optional: section rows under headings. A column id orders rows group-first automatically;
|
|
1148
|
+
// a (row) => GroupKey | string function groups client-side (order the rows yourself).
|
|
1149
|
+
groupBy: "status",
|
|
1147
1150
|
});
|
|
1148
1151
|
</script>
|
|
1149
1152
|
|
|
1150
1153
|
<DataTable
|
|
1151
1154
|
{grid}
|
|
1152
1155
|
selectable
|
|
1156
|
+
collapsibleGroups
|
|
1153
1157
|
rowActions={(row) => [{ label: "Edit", icon: IconPencil, onclick: () => edit(row) }]}
|
|
1154
1158
|
/>
|
|
1155
1159
|
```
|
|
1156
1160
|
|
|
1161
|
+
**Grouping** sections the *current page*: a group header (label + per-page count) prints at each
|
|
1162
|
+
group-key change, `collapsibleGroups` makes the whole header row a collapse toggle, and a `groupHeader`
|
|
1163
|
+
snippet (`{ key, label, count, collapsed, toggle }`) overrides the default. A group spanning a page edge
|
|
1164
|
+
repeats its header on the next page — pair `groupBy` with a group-key sort and a large/`"all"` perPage
|
|
1165
|
+
for complete groups. `localFetch` orders a column-id `groupBy` group-first for you; a remote `query()`
|
|
1166
|
+
reads `GridState.group` (`{ key, dir }`) and does `ORDER BY <group>, <sort>`.
|
|
1167
|
+
|
|
1157
1168
|
`import { createGrid, localFetch, matchesRow } from "@spaethtech/svelte-ui/data";` — `localFetch` is the
|
|
1158
1169
|
client-side counterpart to a remote `query()`; `matchesRow(row, ast)` is the underlying AST evaluator if
|
|
1159
1170
|
you need to filter an array yourself. See the live demo for the full DataTable + Query walkthrough.
|