@spaethtech/svelte-ui 0.21.1-dev.132.b812036 → 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.
@@ -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 rows as row, rowIndex (grid.getRowId(row))}
386
- {@const id = grid.getRowId(row)}
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;
@@ -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 { q: this.applied, sort: this.sort, page: this.page, perPage: this.perPage };
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);
@@ -50,10 +50,23 @@ export function localFetch(source, columns, opts = {}) {
50
50
  now: opts.now?.(),
51
51
  }))
52
52
  : all.slice();
53
- if (state.sort) {
54
- const key = sortKeyOf(state.sort.col);
55
- const dir = state.sort.dir === "desc" ? -1 : 1;
56
- filtered.sort((a, b) => naturalCompare(value(a, key), value(b, key)) * dir);
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[];
@@ -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 controls are
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.132.b812036",
3
+ "version": "0.21.1-dev.134.0bfe62d",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"