@tristan2828/ui-foundation 4.45.0 → 4.46.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.
@@ -4,21 +4,21 @@
4
4
  ## Index
5
5
 
6
6
  - [The reference files](#the-reference-files) · lines 37–59
7
- - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–706
7
+ - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–715
8
8
  - Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
9
9
  - Step 2: `npm run gen:api` · lines 107–108
10
10
  - Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
11
11
  - Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 121–130
12
12
  - Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
13
- - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–598
14
- - Step 7: Register routes · lines 599–609
15
- - Step 8: Add Playwright specs for all three screens · lines 610–688
16
- - Step 9: Register the nav entry and route names · lines 689–703
17
- - Step 10: `npm run verify`. · lines 704–706
18
- - [What not to copy](#what-not-to-copy) · lines 708–734
19
- - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 736–753
20
- - [Deleting](#deleting) · lines 755–784
21
- - [Fields only a script writes](#fields-only-a-script-writes) · lines 786–807
13
+ - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–607
14
+ - Step 7: Register routes · lines 608–618
15
+ - Step 8: Add Playwright specs for all three screens · lines 619–697
16
+ - Step 9: Register the nav entry and route names · lines 698–712
17
+ - Step 10: `npm run verify`. · lines 713–715
18
+ - [What not to copy](#what-not-to-copy) · lines 717–743
19
+ - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 745–762
20
+ - [Deleting](#deleting) · lines 764–793
21
+ - [Fields only a script writes](#fields-only-a-script-writes) · lines 795–816
22
22
 
23
23
  <!-- /index -->
24
24
 
@@ -410,6 +410,15 @@ is a filled-in example while the Widgets demo is still here).
410
410
  view is `rail`, with Details in the rail. The rail's sections come
411
411
  first in the page (header, rail, main) at every width, so the
412
412
  view's specs find them first too.
413
+ - **A subtitle** (`subtitle`), only when the plan names a line that
414
+ says what the record is for (a goal, a one-line summary): under the
415
+ title in muted body type. When the plan marks it editable, wrap it
416
+ in `<EditableValue label=… edit={editInPlace(…)} layout="inline">`.
417
+ - **Key numbers** (`strip`), only when the plan names a few figures
418
+ read before anything else (done of total, time left, the next
419
+ date): `<KeyStats stats={[{ label, value, hint? }]} />`, under the
420
+ header across the page, so on a phone it comes before the rail and
421
+ the main column. Three to five; more belong in a section.
413
422
  - **A notice** (`notice`), only when the plan names a state that
414
423
  decides what you do with the record (held, overdue, archived and
415
424
  read-only): a `<Notice tone=…>` saying what it means and what to do,
@@ -3,37 +3,38 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [The rules that apply to every cell](#the-rules-that-apply-to-every-cell) · lines 65–84
7
- - [1. Plain text](#1-plain-text) · lines 86–93
8
- - [2. Icon + label](#2-icon--label) · lines 95–118
9
- - [3. Icon in a badge](#3-icon-in-a-badge) · lines 120–133
10
- - [4. Icon only, label in a tooltip](#4-icon-only-label-in-a-tooltip) · lines 135–212
11
- - [Several values: the icons side by side, every name in one tooltip](#several-values-the-icons-side-by-side-every-name-in-one-tooltip) · lines 160–212
12
- - [5. Enum → tone-mapped badge](#5-enum--tone-mapped-badge) · lines 214–227
13
- - [6. Multi-value enum, clustered](#6-multi-value-enum-clustered) · lines 229–241
14
- - [7. Link as an outline button](#7-link-as-an-outline-button) · lines 243–264
15
- - [8. Date, relative](#8-date-relative) · lines 266–303
16
- - [9. Ordinal scale (bounded integer)](#9-ordinal-scale-bounded-integer) · lines 305–353
17
- - [Two variants the package draws: a step bar and a star](#two-variants-the-package-draws-a-step-bar-and-a-star) · lines 323–353
18
- - [10. Summary in the cell, detail in a popover](#10-summary-in-the-cell-detail-in-a-popover) · lines 355–405
19
- - [A second example: what a row depends on](#a-second-example-what-a-row-depends-on) · lines 373–405
20
- - [11. Number or currency — Unproven](#11-number-or-currency--unproven) · lines 407–447
21
- - [12. Boolean](#12-boolean) · lines 449–479
22
- - [13. Progress bar — Unproven](#13-progress-bar--unproven) · lines 481–517
23
- - [The compact form: `ProgressRing`](#the-compact-form-progressring) · lines 501–517
24
- - [14. Avatar and name — Unproven](#14-avatar-and-name--unproven) · lines 519–536
25
- - [15. Yes/no you flip in the row](#15-yesno-you-flip-in-the-row) · lines 538–647
26
- - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
27
- - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
28
- - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–759
29
- - [18. Editable value](#18-editable-value) · lines 761–1207
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1023–1108
31
- - [In a table's rows](#in-a-tables-rows) · lines 1110–1195
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1197–1207
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1209–1275
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1277–1341
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1343–1408
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1410–1438
6
+ - [The rules that apply to every cell](#the-rules-that-apply-to-every-cell) · lines 66–85
7
+ - [1. Plain text](#1-plain-text) · lines 87–94
8
+ - [2. Icon + label](#2-icon--label) · lines 96–119
9
+ - [3. Icon in a badge](#3-icon-in-a-badge) · lines 121–134
10
+ - [4. Icon only, label in a tooltip](#4-icon-only-label-in-a-tooltip) · lines 136–213
11
+ - [Several values: the icons side by side, every name in one tooltip](#several-values-the-icons-side-by-side-every-name-in-one-tooltip) · lines 161–213
12
+ - [5. Enum → tone-mapped badge](#5-enum--tone-mapped-badge) · lines 215–228
13
+ - [6. Multi-value enum, clustered](#6-multi-value-enum-clustered) · lines 230–242
14
+ - [7. Link as an outline button](#7-link-as-an-outline-button) · lines 244–265
15
+ - [8. Date, relative](#8-date-relative) · lines 267–304
16
+ - [9. Ordinal scale (bounded integer)](#9-ordinal-scale-bounded-integer) · lines 306–354
17
+ - [Two variants the package draws: a step bar and a star](#two-variants-the-package-draws-a-step-bar-and-a-star) · lines 324–354
18
+ - [10. Summary in the cell, detail in a popover](#10-summary-in-the-cell-detail-in-a-popover) · lines 356–406
19
+ - [A second example: what a row depends on](#a-second-example-what-a-row-depends-on) · lines 374–406
20
+ - [11. Number or currency — Unproven](#11-number-or-currency--unproven) · lines 408–448
21
+ - [12. Boolean](#12-boolean) · lines 450–480
22
+ - [13. Progress bar — Unproven](#13-progress-bar--unproven) · lines 482–518
23
+ - [The compact form: `ProgressRing`](#the-compact-form-progressring) · lines 502–518
24
+ - [14. Avatar and name — Unproven](#14-avatar-and-name--unproven) · lines 520–537
25
+ - [15. Yes/no you flip in the row](#15-yesno-you-flip-in-the-row) · lines 539–648
26
+ - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
27
+ - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
28
+ - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
29
+ - [18. Editable value](#18-editable-value) · lines 762–1208
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1024–1109
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1111–1196
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1198–1208
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1210–1276
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1278–1342
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1344–1412
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1414–1442
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1444–1518
37
38
 
38
39
  <!-- /index -->
39
40
 
@@ -1405,7 +1406,10 @@ const { page, filters, setFilter, setPage } = useTableUrlState(['kind'])
1405
1406
  - **No rows for a filter** is the table's own empty state, saying which
1406
1407
  filter, with a button to take it off. An empty group never renders:
1407
1408
  groups come from the rows.
1408
- - **Not built:** collapsing groups, grouping by more than one key.
1409
+ - **Headings that fold, picked by the reader** are pattern 23's
1410
+ `groupings`: a Group menu over choice fields. `groupBy` stays the
1411
+ app's one fixed grouping (a feed's days). **Not built:** grouping by
1412
+ more than one key.
1409
1413
 
1410
1414
  ## 22. A highlighted row, and rows that are done
1411
1415
 
@@ -1437,3 +1441,78 @@ current one. `DataTable`'s `highlightRow` returns true for it.
1437
1441
  the token check in both themes; `e2e/data-table.spec.ts` checks every
1438
1442
  cell wears the wash.
1439
1443
 
1444
+ ## 23. A Group menu, and a record read beside the list
1445
+
1446
+ A working list (tasks, chores, a backlog) is read two ways besides its
1447
+ columns: **grouped by one of its choice fields**, picked by the reader
1448
+ (by status to see what's moving, by area to plan a trip round the
1449
+ house), and **one record at a time without leaving the list**, its notes
1450
+ and key facts in a panel beside the rows. `DataTable`'s `groupings` and
1451
+ `detailPanel` (4.46) do both. Raised for an app's task list; the
1452
+ package's Storybook renders it (`app/DataTable grouping`), with axe in
1453
+ both themes and its specs in a real browser.
1454
+
1455
+ ```tsx
1456
+ const GROUPINGS: DataTableGrouping<Task>[] = [ // module level
1457
+ { id: 'status', label: 'Status', options: STATUS_OPTIONS, value: (task) => task.status },
1458
+ { id: 'categories', label: 'Category', options: CATEGORY_OPTIONS, value: (task) => task.categories, emptyLabel: 'No category' },
1459
+ { id: 'canDoNow', label: 'Can do now', options: [{ value: 'yes', label: 'Can do now' }, { value: 'no', label: 'Not yet' }],
1460
+ value: (task) => (task.canDoNow ? 'yes' : 'no') },
1461
+ ]
1462
+
1463
+ const { grouping, setGrouping, ...table } = useTableUrlState(FILTERS, MULTI, VIEWS, { groupStorageKey: 'my-app:tasks:group' })
1464
+
1465
+ <DataTable
1466
+ groupings={GROUPINGS}
1467
+ grouping={grouping}
1468
+ onGroupingChange={setGrouping}
1469
+ groupSummary={(tasks) => `${tasks.filter(isReady).length} ready · top ${Math.max(...tasks.map((task) => task.score))}`}
1470
+ detailLabel="Task"
1471
+ detailPanel={(task) => <TaskPreview task={task} />} // its own "Open page" link
1472
+ … />
1473
+ ```
1474
+
1475
+ - **None is the default**, and the table is exactly as it was until a
1476
+ grouping is picked. The Group menu sits at the end of the toolbar
1477
+ (before a layout switch): None, then each grouping. While grouped a
1478
+ Fold all button joins it, and the menu says which ("Group: Status").
1479
+ - **Only choice fields**: each group is one of the field's options, in
1480
+ the options' order (the order the form lists them), so give the same
1481
+ options list the column and the form use. A multi-value field (an
1482
+ array) shows the row under each of its values; a row with none goes
1483
+ last, under `emptyLabel` ("Not set" unless set). A yes/no field is two
1484
+ options, worded as headings ("Can do now", "Not yet").
1485
+ - **The grouped column is hidden** while grouped (its value is the
1486
+ heading), so a grouping's `id` is its column's id.
1487
+ - **A heading is a button** (`aria-expanded`) that folds its rows away:
1488
+ a chevron, the label, the count, then `groupSummary`'s line, given the
1489
+ group's rows *on this page* and the group itself. Folds reset when the
1490
+ grouping changes.
1491
+ - **Remembered like the layout**: `useTableUrlState`'s `grouping` is
1492
+ `?group=` in the URL, then the one remembered under `groupStorageKey`
1493
+ on this device, then None. A saved view can name a `group` (''
1494
+ for None) to turn one on when pressed; a view that names none keeps
1495
+ the grouping that's on. Grouping doesn't change which rows are shown,
1496
+ so it keeps the page and doesn't count toward the active view.
1497
+ - **It groups the page's rows**: paging pages rows, not groups, so a
1498
+ list that groups wants a page size that holds it (a working list of a
1499
+ few dozen), and a summary that must count a whole group across pages
1500
+ comes from the API. A grouping, while on, takes the place of
1501
+ `groupBy`'s headings.
1502
+ - **The detail panel** opens on a click on a row, or Enter on it (its
1503
+ title link included: a plain click picks the row, a modified one still
1504
+ opens the link in a new tab, and the panel's own "Open page" link
1505
+ leaves). A control in a cell (a button, a switch, an editable value)
1506
+ stays the control's. The picked row is marked (`aria-selected`, the
1507
+ muted fill and a bar in `--primary` at its start); ↑ and ↓ move the pick
1508
+ while the list has focus, and Escape or Close closes it, focus back on
1509
+ the row. On a wide screen (64rem and up) the panel sits beside the
1510
+ table and stays in view as the page scrolls; below that it is a
1511
+ bottom sheet.
1512
+ - **The panel is the app's**: a heading-sized title, a few badges, a
1513
+ short notes preview (a few lines of the record's notes, not the whole
1514
+ editor), and an "Open page" link (a `<Link>` in `text-link`, so it's
1515
+ announced as the link it is). Without `detailPanel` rows behave as they
1516
+ always have: the title link opens the record's page.
1517
+ - **The table layout's alone:** a card layout's cards don't open the
1518
+ panel.
@@ -3,23 +3,24 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [Semantic tones](#semantic-tones) · lines 32–60
7
- - [The three badge styles](#the-three-badge-styles) · lines 62–79
8
- - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 81–110
9
- - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 112–125
10
- - [Logos and brand marks](#logos-and-brand-marks) · lines 127–171
11
- - [Categorical colour](#categorical-colour) · lines 173–243
12
- - [Typography](#typography) · lines 245–327
13
- - [Written text (notes)](#written-text-notes) · lines 276–327
14
- - [Density](#density) · lines 329–386
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–502
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 504–562
17
- - [Notices](#notices) · lines 564–592
18
- - [A value over time](#a-value-over-time) · lines 594–627
19
- - [A list as a board](#a-list-as-a-board) · lines 629–680
20
- - [A list as cards](#a-list-as-cards) · lines 682–728
21
- - [Picking at random](#picking-at-random) · lines 730–761
22
- - [Filling in what's missing](#filling-in-whats-missing) · lines 763–813
6
+ - [Semantic tones](#semantic-tones) · lines 33–61
7
+ - [The three badge styles](#the-three-badge-styles) · lines 63–80
8
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 82–111
9
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 113–126
10
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 128–172
11
+ - [Categorical colour](#categorical-colour) · lines 174–244
12
+ - [Typography](#typography) · lines 246–328
13
+ - [Written text (notes)](#written-text-notes) · lines 277–328
14
+ - [Density](#density) · lines 330–387
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 389–503
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–563
17
+ - [Notices](#notices) · lines 565–593
18
+ - [A value over time](#a-value-over-time) · lines 595–628
19
+ - [A list as a board](#a-list-as-a-board) · lines 630–681
20
+ - [A list as cards](#a-list-as-cards) · lines 683–729
21
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 731–753
22
+ - [Picking at random](#picking-at-random) · lines 755–786
23
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 788–838
23
24
 
24
25
  <!-- /index -->
25
26
 
@@ -684,8 +685,8 @@ the table's.
684
685
  `DataTable`'s `layouts` (4.39) draw the same rows as cards beside the
685
686
  table: a thumbnail grid for a feed, covers for a collection, a dense line
686
687
  each for clearing a backlog. They are layouts of one list, not other
687
- pages: the toolbar, filters, saved views, paging and `groupBy` headings
688
- are the table's in every one, and so are its loading, empty and error
688
+ pages: the toolbar, filters, saved views, paging, `groupBy` headings
689
+ and a picked grouping's folding ones (cell pattern 23) are the table's in every one, and so are its loading, empty and error
689
690
  states.
690
691
 
691
692
  ```tsx
@@ -727,6 +728,30 @@ const { layout, setLayout } = useTableLayout({
727
728
  - `totals` and `highlightRow` are the table's alone. Sorting is the
728
729
  toolbar's in a card layout (there is no header to click).
729
730
 
731
+ ## Key numbers on a record
732
+
733
+ `KeyStats` (4.46), in `EntityView`'s `strip`: the few figures a record's
734
+ page is read for first (done of total, time left, the next date), in a
735
+ row under the header, across the page at every width.
736
+
737
+ ```tsx
738
+ <EntityView
739
+ subtitle={<EditableValue label="Goal" edit={goals} layout="inline">{goal}</EditableValue>}
740
+ strip={<KeyStats stats={[{ label: 'Done', value: '7', hint: 'of 12' }, { label: 'Next step', value: formatPlainDate(next) }]} />}
741
+ … />
742
+ ```
743
+
744
+ - **A label over its value**: the label in caption type and muted, the
745
+ value in the section title's size and tabular figures, a `hint`
746
+ under it in caption type. One bordered card, the figures side by side,
747
+ wrapping on a phone. A `<dl>`, so each label is read with its value.
748
+ - **An empty value reads as `emptyLabel`** ("Not set"), in body type and
749
+ muted, never a blank or a dash.
750
+ - **Three to five.** It's what the page is for at a glance; anything
751
+ more is a section's label/value rows.
752
+ - **`subtitle`** is the line under the title: what the record is for,
753
+ in muted body type, editable in place like any value.
754
+
730
755
  ## Picking at random
731
756
 
732
757
  `SpinWheel` (4.42) picks one of a few candidates by chance, for "what do
@@ -82,10 +82,43 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
82
82
  */
83
83
  groupBy?: (row: TData) => RowGroup;
84
84
  /**
85
- * What a heading says after its label ("4 · 1 h 12 m"), from the group's
86
- * rows on this page.
85
+ * What a heading says after its label ("4 · 1 h 12 m", "3 ready · top
86
+ * 42"), from the group's rows on this page, for `groupBy`'s headings and
87
+ * a grouping's alike. `group` says which group it is.
87
88
  */
88
- groupSummary?: (rows: TData[]) => ReactNode;
89
+ groupSummary?: (rows: TData[], group: RowGroup) => ReactNode;
90
+ /**
91
+ * The fields the rows can be grouped by, picked in a Group menu at the
92
+ * end of the toolbar: None (the default, the table as it is without
93
+ * this), then each of these. Choice fields: each group is one of the
94
+ * field's options, in the options' order. While grouped, the field's
95
+ * column is hidden (its value is the heading), each heading folds its
96
+ * rows away and says how many there are, with `groupSummary` after it,
97
+ * and a Fold all button joins the menu. A grouping, while on, takes the
98
+ * place of `groupBy`. It groups this page's rows: paging still pages
99
+ * rows.
100
+ */
101
+ groupings?: readonly DataTableGrouping<TData>[];
102
+ /**
103
+ * The grouping on: a grouping's id, or '' for None, from
104
+ * useTableUrlState's `grouping` (the URL's `?group=`, then the one
105
+ * remembered for this table). An id the table doesn't have is None.
106
+ * Left out, the menu keeps its own state, starting on None.
107
+ */
108
+ grouping?: string;
109
+ onGroupingChange?: (grouping: string) => void;
110
+ /**
111
+ * A record read beside the list without leaving it. Clicking a row (its
112
+ * title link too; a modified click still opens the link) or Enter on it
113
+ * selects it and opens this panel: beside the table on a wide screen
114
+ * (64rem and up), a bottom sheet below that. ↑ and ↓ move the selection
115
+ * while the list has focus; Escape or Close closes it. Everything in
116
+ * the panel is the app's, an "Open page" link included. The table
117
+ * layout's alone: a card layout's cards are as they are.
118
+ */
119
+ detailPanel?: (row: TData) => ReactNode;
120
+ /** The panel's heading and landmark name: "Details" unless set. */
121
+ detailLabel?: string;
89
122
  /**
90
123
  * False hides the column header row, for a list-like table whose columns
91
124
  * explain themselves (a thumbnail, a title block, actions). Screen readers
@@ -132,6 +165,30 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
132
165
  icon?: IconComponent;
133
166
  };
134
167
  };
168
+ /** A field DataTable's rows can be grouped by (`groupings`). */
169
+ export type DataTableGrouping<TData> = {
170
+ /**
171
+ * The field's column id, hidden while grouped, and the grouping's id in
172
+ * the URL (`?group=status`) and in a saved view's `group`.
173
+ */
174
+ id: string;
175
+ /** The menu's word for it ("Status"), and the menu button's while it's on ("Group: Status"). */
176
+ label: string;
177
+ /** The field's options, in order: the groups, their order and their headings. */
178
+ options: readonly {
179
+ value: string;
180
+ label: string;
181
+ }[];
182
+ /**
183
+ * The row's value: one option's value, or a list of them for a
184
+ * multi-value field, whose row then shows under each. A value the
185
+ * options don't name gets a group of its own after theirs, headed by
186
+ * the value itself.
187
+ */
188
+ value: (row: TData) => string | readonly string[] | null | undefined;
189
+ /** The heading over rows with no value, last: "Not set" unless set ("No category"). */
190
+ emptyLabel?: string;
191
+ };
135
192
  /** One of DataTable's card layouts (`layouts`). */
136
193
  export type DataTableCardLayout<TData> = {
137
194
  /** In the URL (`?layout=covers`) and remembered, so keep it short and stable. */
@@ -167,4 +224,4 @@ export type DataTableTotals = {
167
224
  */
168
225
  cells?: Partial<Record<string, ReactNode>>;
169
226
  };
170
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, }: DataTableProps<TData>): import("react").JSX.Element;
227
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, groupings, grouping: groupingProp, onGroupingChange, detailPanel, detailLabel, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -14,16 +14,18 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } fr
14
14
  import { flexRender } from '@tanstack/react-table';
15
15
  import { cn } from 'cn';
16
16
  import { getCoreRowModel, useLegacyTable } from '@tanstack/react-table/legacy';
17
- import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, TableIcon } from 'lucide-react';
17
+ import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ListTreeIcon, TableIcon, XIcon, } from 'lucide-react';
18
18
  import { ErrorState } from './error-state.js';
19
19
  import { Button } from '../ui/button.js';
20
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
20
21
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
22
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet.js';
21
23
  import { Skeleton } from '../ui/skeleton.js';
22
24
  import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
23
25
  import { EditInPlaceContext } from './edit-in-place.js';
24
26
  import { EditInPlaceStore } from './edit-in-place-store.js';
25
27
  import { LeaveGuard } from './editable-value.js';
26
- import { groupRuns } from './table-groups.js';
28
+ import { groupByValues, groupRuns } from './table-groups.js';
27
29
  import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '../ui/table.js';
28
30
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
29
31
  // `accessorKey` with dots as underscores, then a string header. Group
@@ -74,6 +76,35 @@ function LayoutSwitch({ options, value, onValueChange, }) {
74
76
  onValueChange(chosen);
75
77
  }, children: options.map(({ id, label, icon: Icon }) => (_jsxs(ToggleGroupItem, { value: id, className: LAYOUT_ITEM_CLASS, title: label, children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4" }), _jsx("span", { className: "max-sm:sr-only", children: label })] }, id))) }));
76
78
  }
79
+ // The Group menu (`groupings`): a radio list, None first, that closes on
80
+ // a pick. Base UI's radio group needs a value for None, so it's this
81
+ // stand-in, never '' (the grouping's own word for None).
82
+ const NO_GROUPING = '__none__';
83
+ function GroupMenu({ groupings, value, onValueChange, }) {
84
+ const current = groupings.find((grouping) => grouping.id === value);
85
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", "data-slot": "data-table-group-menu" }), children: [_jsx(ListTreeIcon, { "aria-hidden": "true" }), current ? `Group: ${current.label}` : 'Group'] }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Group by" }), _jsxs(DropdownMenuRadioGroup, { value: current?.id ?? NO_GROUPING, onValueChange: (next) => onValueChange(next === NO_GROUPING ? '' : String(next)), children: [_jsx(DropdownMenuRadioItem, { value: NO_GROUPING, closeOnClick: true, children: "None" }), groupings.map((grouping) => (_jsx(DropdownMenuRadioItem, { value: grouping.id, closeOnClick: true, children: grouping.label }, grouping.id)))] })] }) })] }));
86
+ }
87
+ // Whether the detail panel (`detailPanel`) fits beside the table, or opens
88
+ // as a bottom sheet. Wide on the server and wherever matchMedia is missing
89
+ // (a test's DOM), so markup never depends on it.
90
+ const WIDE_QUERY = '(min-width: 64rem)';
91
+ const canMatchMedia = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function';
92
+ function subscribeWide(listener) {
93
+ if (!canMatchMedia())
94
+ return () => { };
95
+ const query = window.matchMedia(WIDE_QUERY);
96
+ query.addEventListener('change', listener);
97
+ return () => query.removeEventListener('change', listener);
98
+ }
99
+ const useIsWide = () => useSyncExternalStore(subscribeWide, () => !canMatchMedia() || window.matchMedia(WIDE_QUERY).matches, () => true);
100
+ // A click on one of these inside a row is the control's own, not a pick of
101
+ // the row. A link is the exception: a plain click picks the row instead of
102
+ // following it (a modified one still opens it, in a new tab).
103
+ const ROW_CONTROL_SELECTOR = 'a[href],button,input,select,textarea,label,summary,[contenteditable="true"],' +
104
+ '[role="button"],[role="checkbox"],[role="switch"],[role="combobox"],[role="link"],[role="slider"]';
105
+ // Keys typed into one of these are its own: ↑/↓ and Escape stay with it.
106
+ const TYPING_SELECTOR = 'input,select,textarea,[contenteditable="true"],[role="combobox"],[role="listbox"],[role="menu"],' +
107
+ '[role="slider"],[role="spinbutton"]';
77
108
  function TableSkeleton({ columnCount }) {
78
109
  return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
79
110
  }
@@ -140,6 +171,25 @@ const HIDDEN_HEAD_CLASS = 'h-0 p-0';
140
171
  const ALIGN_CLASS = { center: 'text-center', right: 'text-right' };
141
172
  const alignClass = (align) => align && ALIGN_CLASS[align];
142
173
  const GROUP_HEADING_CLASS = 'h-auto bg-background pt-4 pb-1.5 align-bottom';
174
+ // A grouping's heading (`groupings`) is a button that folds its rows: the
175
+ // label, then the count, a chevron that turns when folded.
176
+ const GROUP_TOGGLE_CLASS = '-mx-1 inline-flex items-baseline gap-2 rounded-sm px-1 outline-none hover:underline ' +
177
+ 'focus-visible:ring-3 focus-visible:ring-ring/50';
178
+ // `detailPanel`: a row that can be picked shows a pointer and its focus
179
+ // ring inside it (a row has no outline of its own room); the picked one
180
+ // takes the muted fill on every cell, over the zebra stripe and the pinned
181
+ // cells' opaque backgrounds as highlightRow's wash does, with a bar in
182
+ // --primary at its start, so it reads without colour too.
183
+ const SELECTABLE_ROW_CLASS = 'cursor-pointer outline-none focus-visible:[&>td]:shadow-[inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
184
+ const SELECTED_ROW_CLASS = '[&>td]:bg-muted [&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary)] ' +
185
+ 'focus-visible:[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary),inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
186
+ // The table and the panel side by side once one is picked on a wide
187
+ // screen. The grid is there from the start, so opening the panel never
188
+ // remounts the table (and the focused row with it).
189
+ const WITH_DETAIL_OPEN_CLASS = 'grid-cols-[minmax(0,1fr)_minmax(18rem,26rem)]';
190
+ const DETAIL_ASIDE_CLASS = 'sticky top-4 flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col gap-3 overflow-y-auto rounded-lg border ' +
191
+ 'border-border bg-card p-4 text-card-foreground';
192
+ const EMPTY_KEYS = new Set();
143
193
  // `width="content"`: from a table area 80rem wide, as wide as the columns
144
194
  // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
145
195
  // width it last had loaded while it shows another state
@@ -208,7 +258,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
208
258
  }, [containerRef, isSuccessView, isOverflowing]);
209
259
  return { barRef, isOverflowing, scrollWidth };
210
260
  }
211
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, }) {
261
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, groupings, grouping: groupingProp, onGroupingChange, detailPanel, detailLabel = 'Details', }) {
212
262
  // The switch's own state, for a table that doesn't pass `layout`.
213
263
  const [ownLayout, setOwnLayout] = useState('table');
214
264
  const requestedLayout = layoutProp ?? ownLayout;
@@ -219,6 +269,22 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
219
269
  setOwnLayout(next);
220
270
  onLayoutChange?.(next);
221
271
  };
272
+ // The grouping on (`groupings`), the menu's own state for a table that
273
+ // doesn't pass `grouping`.
274
+ const [ownGrouping, setOwnGrouping] = useState('');
275
+ const activeGrouping = groupings?.find((candidate) => candidate.id === (groupingProp ?? ownGrouping));
276
+ const setGrouping = (next) => {
277
+ if (groupingProp === undefined)
278
+ setOwnGrouping(next);
279
+ onGroupingChange?.(next);
280
+ };
281
+ // The folded headings, for the grouping they were folded in: picking
282
+ // another starts with every group open.
283
+ const [folds, setFolds] = useState({ grouping: '', keys: EMPTY_KEYS });
284
+ const folded = activeGrouping && folds.grouping === activeGrouping.id ? folds.keys : EMPTY_KEYS;
285
+ // The picked row (`detailPanel`) and the place it was picked in.
286
+ const [selection, setSelection] = useState(null);
287
+ const isWide = useIsWide();
222
288
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
223
289
  // a cell that renders an <EditableValue> edits where it's shown, one at
224
290
  // a time, with the leave-page prompt while one is open, the same rules
@@ -228,9 +294,13 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
228
294
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
229
295
  // TanStack treats a column missing from this map as visible, so every
230
296
  // column gets an entry once a list is given.
231
- const columnVisibility = visibleColumns
232
- ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
233
- : {};
297
+ // While grouped, the grouped column is hidden: its value is the heading.
298
+ const columnVisibility = {
299
+ ...(visibleColumns
300
+ ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
301
+ : {}),
302
+ ...(activeGrouping ? { [activeGrouping.id]: false } : {}),
303
+ };
234
304
  const table = useLegacyTable({
235
305
  data,
236
306
  columns,
@@ -279,14 +349,124 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
279
349
  const rows = table.getRowModel().rows;
280
350
  const visibleLeafColumns = table.getVisibleLeafColumns();
281
351
  const visibleColumnCount = visibleLeafColumns.length;
352
+ // The groups, if any: a grouping's (folding, with counts), else groupBy's.
353
+ const runs = activeGrouping
354
+ ? groupByValues(rows, (row) => activeGrouping.value(row.original), activeGrouping.options, activeGrouping.emptyLabel ?? 'Not set').map((run) => ({ ...run, foldable: true }))
355
+ : groupBy
356
+ ? groupRuns(rows, (row) => groupBy(row.original)).map((run) => ({ ...run, foldable: false }))
357
+ : null;
358
+ const allFolded = runs !== null && runs.length > 0 && runs.every((run) => folded.has(run.key));
359
+ const toggleFold = (key) => {
360
+ if (!activeGrouping)
361
+ return;
362
+ const keys = new Set(folded);
363
+ if (!keys.delete(key))
364
+ keys.add(key);
365
+ setFolds({ grouping: activeGrouping.id, keys });
366
+ };
367
+ const foldAll = () => {
368
+ if (!activeGrouping || !runs)
369
+ return;
370
+ setFolds({ grouping: activeGrouping.id, keys: allFolded ? EMPTY_KEYS : new Set(runs.map((run) => run.key)) });
371
+ };
372
+ // Every row's place in the list as shown (folded groups left out), in
373
+ // order, for ↑/↓. Under a grouping a place is the group and the row,
374
+ // since a multi-value field shows a row in more than one.
375
+ const placeKey = (runKey, rowId) => activeGrouping ? `${encodeURIComponent(runKey)}:${rowId}` : rowId;
376
+ const places = runs
377
+ ? runs.flatMap((run) => folded.has(run.key) ? [] : run.rows.map((row) => ({ key: placeKey(run.key, row.id), rowId: row.id })))
378
+ : rows.map((row) => ({ key: row.id, rowId: row.id }));
379
+ const selectedRow = detailPanel && selection ? rows.find((row) => row.id === selection.rowId) : undefined;
380
+ // The place marked picked: where it was picked, or (regrouped since) the
381
+ // row's first place. None while its group is folded.
382
+ const selectedKey = selectedRow
383
+ ? places.some((place) => place.key === selection?.key)
384
+ ? selection?.key
385
+ : places.find((place) => place.rowId === selectedRow.id)?.key
386
+ : undefined;
387
+ // The list's one tab stop: the picked row, else the first.
388
+ const tabStopKey = selectedKey ?? places[0]?.key;
389
+ const focusRow = (key) => {
390
+ if (key === undefined)
391
+ return;
392
+ containerRef.current?.querySelector(`tr[data-row-key="${CSS.escape(key)}"]`)?.focus();
393
+ };
394
+ const closeDetail = () => {
395
+ setSelection(null);
396
+ focusRow(selectedKey);
397
+ };
398
+ const onRowClick = (event, place) => {
399
+ const target = event.target;
400
+ // A click from a portal (a picker's popup opened from a cell) reaches
401
+ // the row through React, not the DOM: it's that control's.
402
+ if (!event.currentTarget.contains(target))
403
+ return;
404
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
405
+ return;
406
+ const control = target.closest(ROW_CONTROL_SELECTOR);
407
+ if (control && event.currentTarget.contains(control)) {
408
+ if (!control.matches('a[href]'))
409
+ return;
410
+ // In the capture phase, so react-router's Link sees it prevented and
411
+ // doesn't navigate.
412
+ event.preventDefault();
413
+ }
414
+ setSelection(place);
415
+ };
416
+ const onListKeyDown = (event) => {
417
+ const target = event.target;
418
+ if (event.defaultPrevented || !event.currentTarget.contains(target) || target.closest(TYPING_SELECTOR))
419
+ return;
420
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
421
+ if (places.length === 0)
422
+ return;
423
+ event.preventDefault();
424
+ const down = event.key === 'ArrowDown';
425
+ const index = places.findIndex((place) => place.key === selectedKey);
426
+ const nextIndex = index === -1 ? (down ? 0 : places.length - 1) : Math.min(places.length - 1, Math.max(0, index + (down ? 1 : -1)));
427
+ const next = places[nextIndex];
428
+ if (!next)
429
+ return;
430
+ setSelection(next);
431
+ focusRow(next.key);
432
+ }
433
+ else if (event.key === 'Enter' && target.dataset.rowKey !== undefined) {
434
+ const place = places.find((candidate) => candidate.key === target.dataset.rowKey);
435
+ if (!place)
436
+ return;
437
+ event.preventDefault();
438
+ setSelection(place);
439
+ }
440
+ else if (event.key === 'Escape' && selectedRow) {
441
+ event.preventDefault();
442
+ closeDetail();
443
+ }
444
+ };
282
445
  // One body row, the same in a plain table and a grouped one.
283
- const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
446
+ const renderRow = (row, key = row.id) => {
447
+ const isSelected = key === selectedKey;
448
+ return (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, ...(detailPanel
449
+ ? {
450
+ 'data-row-key': key,
451
+ 'data-selected': isSelected || undefined,
452
+ 'aria-selected': isSelected,
453
+ tabIndex: key === tabStopKey ? 0 : -1,
454
+ onClickCapture: (event) => onRowClick(event, { key, rowId: row.id }),
455
+ }
456
+ : {}), className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS, detailPanel && SELECTABLE_ROW_CLASS, isSelected && SELECTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, key));
457
+ };
284
458
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, "data-layout": layouts?.length ? currentLayout : undefined, style: width === 'content' && !isSuccessView && lastWidth !== null
285
459
  ? { '--data-table-last-width': `${lastWidth}px` }
286
- : undefined, children: [layouts?.length ? (_jsxs("div", { className: "flex flex-wrap items-end gap-x-4 gap-y-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: toolbar }), _jsx(LayoutSwitch, { options: [
460
+ : undefined, children: [layouts?.length || groupings?.length ? (_jsxs("div", { className: "flex flex-wrap items-end gap-x-4 gap-y-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: toolbar }), groupings?.length ? (_jsxs("div", { className: "flex items-center gap-2", children: [activeGrouping && (_jsx(Button, { variant: "ghost", size: "sm", "data-slot": "data-table-fold-all", disabled: !runs?.length, onClick: foldAll, children: allFolded ? 'Unfold all' : 'Fold all' })), _jsx(GroupMenu, { groupings: groupings, value: activeGrouping?.id ?? '', onValueChange: setGrouping })] })) : null, layouts?.length ? (_jsx(LayoutSwitch, { options: [
287
461
  { id: 'table', label: tableLayout?.label ?? 'Table', icon: tableLayout?.icon ?? TableIcon },
288
462
  ...layouts,
289
- ], value: currentLayout, onValueChange: setLayout })] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, rows: rows, groupBy: groupBy, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef,
463
+ ], value: currentLayout, onValueChange: setLayout })) : null] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(DetailFrame, { enabled: Boolean(detailPanel) && !cardLayout, aside: selectedRow && detailPanel && isWide ? (_jsxs("aside", { "aria-label": detailLabel, "data-slot": "data-table-detail", className: DETAIL_ASIDE_CLASS, onKeyDown: (event) => {
464
+ const target = event.target;
465
+ if (event.key !== 'Escape' || event.defaultPrevented || target.closest(TYPING_SELECTOR))
466
+ return;
467
+ event.preventDefault();
468
+ closeDetail();
469
+ }, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h2", { className: "type-label text-muted-foreground", children: detailLabel }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Close", onClick: closeDetail, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }), detailPanel(selectedRow.original)] })) : null, children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, runs: runs, rows: rows, folded: folded, onToggleFold: toggleFold, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef, onKeyDown: detailPanel ? onListKeyDown : undefined,
290
470
  // With the sticky bar below as the visible scrollbar, hide the
291
471
  // container's own. It stays overflow-x-auto and fully
292
472
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -315,18 +495,38 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
315
495
  : sortDirection === 'desc'
316
496
  ? 'descending'
317
497
  : 'none', children: !showHeader ? (_jsx("span", { className: "sr-only", children: content })) : pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
318
- }) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
498
+ }) }, headerGroup.id))) }), runs ? (runs.map((run, runIndex) => {
499
+ const isFolded = folded.has(run.key);
500
+ return (_jsxs(TableBody, { "data-slot": "table-group", "data-folded": isFolded || undefined, children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent has-aria-expanded:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx(GroupLabel, { run: run, isFolded: isFolded, onToggle: () => toggleFold(run.key) }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original), { key: run.key, label: run.label }) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), !isFolded && run.rows.map((row) => renderRow(row, placeKey(run.key, row.id)))] }, `${runIndex}:${run.key}`));
501
+ })) : (_jsx(TableBody, { children: rows.map((row) => renderRow(row)) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
319
502
  const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
320
503
  return index === 0 ? (_jsx(TableHead, { scope: "row", className: cn(TOTALS_LABEL_CLASS, className), children: pinFirstColumn ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: totals.label })) : (totals.label) }, column.id)) : (_jsx(TableCell, { className: className, children: totals.cells?.[column.id] }, column.id));
321
504
  }) }) }))] })), pinFirstColumn && isOverflowing && !cardLayout && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
322
- return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
505
+ return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content, detailPanel && (_jsx(Sheet, { open: Boolean(selectedRow) && !isWide && isSuccessView && !cardLayout, onOpenChange: (open) => {
506
+ if (!open)
507
+ setSelection(null);
508
+ }, children: _jsxs(SheetContent, { side: "bottom", className: "max-h-[85dvh] overflow-y-auto", "data-slot": "data-table-detail-sheet", children: [_jsx(SheetHeader, { className: "pb-0", children: _jsx(SheetTitle, { children: detailLabel }) }), _jsx("div", { className: "px-4 pb-4", children: selectedRow && detailPanel(selectedRow.original) })] }) }))] }));
509
+ }
510
+ // The list and, once a row is picked on a wide screen, the detail panel
511
+ // beside it (`detailPanel`). Without a panel it adds nothing.
512
+ function DetailFrame({ enabled, aside, children }) {
513
+ if (!enabled)
514
+ return _jsx(_Fragment, { children: children });
515
+ return (_jsxs("div", { "data-slot": "data-table-with-detail", className: cn('grid items-start gap-4', aside && WITH_DETAIL_OPEN_CLASS), children: [_jsx("div", { className: "flex min-w-0 flex-col gap-4", children: children }), aside] }));
516
+ }
517
+ // A group's heading text: under a grouping, a button that folds the group,
518
+ // with its row count; under `groupBy`, the label alone.
519
+ function GroupLabel({ run, isFolded, onToggle, }) {
520
+ if (!run.foldable)
521
+ return _jsx("span", { className: "type-label text-foreground", children: run.label });
522
+ return (_jsxs("button", { type: "button", "aria-expanded": !isFolded, onClick: onToggle, className: GROUP_TOGGLE_CLASS, children: [_jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('size-4 self-center text-muted-foreground transition-transform', isFolded && '-rotate-90') }), _jsx("span", { className: "type-label text-foreground", children: run.label }), _jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: run.rows.length })] }));
323
523
  }
324
- // A card layout's success view: the rows as cards in a grid or a list,
325
- // each run of one group (`groupBy`) under its own heading, as the table's
326
- // heading rows are, with the same label and summary.
327
- function Cards({ layout, rows, groupBy, groupSummary, }) {
524
+ function Cards({ layout, runs, rows, folded, onToggleFold, groupSummary, }) {
328
525
  const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0", children: layout.renderCard(row.original) }, row.id))) }));
329
- return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: groupBy
330
- ? groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs("section", { "data-slot": "table-group", className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }), list(run.rows)] }, `${runIndex}:${run.key}`)))
526
+ return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: runs
527
+ ? runs.map((run, runIndex) => {
528
+ const isFolded = folded.has(run.key);
529
+ return (_jsxs("section", { "data-slot": "table-group", "data-folded": isFolded || undefined, className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", children: [_jsx(GroupLabel, { run: run, isFolded: isFolded, onToggle: () => onToggleFold(run.key) }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original), { key: run.key, label: run.label }) }))] }), !isFolded && list(run.rows)] }, `${runIndex}:${run.key}`));
530
+ })
331
531
  : list(rows) }));
332
532
  }
@@ -66,6 +66,12 @@ export type EntityViewProps = {
66
66
  titleEdit?: EditInPlace & {
67
67
  label: string;
68
68
  };
69
+ /**
70
+ * A line under the title, in muted body type: what the record is for (a
71
+ * goal, a one-line summary). Any markup, an `<EditableValue>` included,
72
+ * so it can be edited in place where it's shown.
73
+ */
74
+ subtitle?: ReactNode;
69
75
  /**
70
76
  * A few status-like values beside the title, as `<Badge>`s, or as the
71
77
  * control that changes one when the plan makes it a quick action (a
@@ -80,6 +86,13 @@ export type EntityViewProps = {
80
86
  * every width. Announced when it appears or changes after the page loads.
81
87
  */
82
88
  notice?: ReactNode;
89
+ /**
90
+ * A row of the record's key numbers (`<KeyStats>`) under the header and
91
+ * its notice, across the page at every width: in the rail layout above
92
+ * both columns, so on a phone it comes before the rail and the main
93
+ * column.
94
+ */
95
+ strip?: ReactNode;
83
96
  sections: EntityViewSection[];
84
97
  /**
85
98
  * Below every section (both columns in the rail layout), in small muted
@@ -101,4 +114,4 @@ export type EntityViewProps = {
101
114
  label: string;
102
115
  };
103
116
  };
104
- export declare function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
117
+ export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -29,8 +29,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
29
29
  // autosave as it's typed (`autosave`), and be the editor itself, with no
30
30
  // click to open (`alwaysEditable`).
31
31
  //
32
+ // In the header, `subtitle` under the title (a goal, a one-line summary).
32
33
  // Around the sections: `notice` (a <Notice>) under the header, across the
33
- // page, for a state that decides what you do with the record; `footer`
34
+ // page, for a state that decides what you do with the record; `strip` (a
35
+ // <KeyStats>) under that, the record's key numbers; `footer`
34
36
  // (a <SourceCredit>) after them all, for crediting the data's source.
35
37
  //
36
38
  // Two layouts. `column` (the default): every section in one column capped
@@ -141,7 +143,7 @@ function Rail({ label, sections }) {
141
143
  const [ref, fits] = useFitsWindow();
142
144
  return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
143
145
  }
144
- export function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
146
+ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
145
147
  // Editing in place: one store per view, and the leave-page prompt only
146
148
  // while a field is open (or one is always editable).
147
149
  const [store] = useState(() => new EditInPlaceStore());
@@ -158,5 +160,5 @@ export function EntityView({ title, documentTitle, titleEdit, badges, actions, n
158
160
  return _jsx(EntityViewSkeleton, { layout: layout });
159
161
  const railSections = sections.filter((section) => section.placement === 'rail');
160
162
  const mainSections = sections.filter((section) => section.placement !== 'rail');
161
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
163
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
162
164
  }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+ export type KeyStat = {
3
+ label: string;
4
+ value: ReactNode;
5
+ /** A few words under the value ("of 12", "since May"), in caption type. */
6
+ hint?: ReactNode;
7
+ };
8
+ export type KeyStatsProps = {
9
+ stats: readonly KeyStat[];
10
+ /** What an empty value reads as. Defaults to "Not set". */
11
+ emptyLabel?: string;
12
+ className?: string;
13
+ };
14
+ export declare function KeyStats({ stats, emptyLabel, className }: KeyStatsProps): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from 'cn';
3
+ const isEmpty = (value) => value === null || value === undefined || value === false || value === '';
4
+ export function KeyStats({ stats, emptyLabel = 'Not set', className }) {
5
+ return (_jsx("dl", { "data-slot": "key-stats", className: cn('flex flex-wrap gap-x-8 gap-y-3 rounded-lg border border-border bg-card px-4 py-3 text-card-foreground', className), children: stats.map((stat) => (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("dt", { className: "type-caption text-muted-foreground", children: stat.label }), _jsx("dd", { className: "type-section-title tabular-nums break-words text-foreground", children: isEmpty(stat.value) ? _jsx("span", { className: "type-body text-muted-foreground", children: emptyLabel }) : stat.value }), stat.hint && _jsx("dd", { className: "type-caption text-muted-foreground", children: stat.hint })] }, stat.label))) }));
6
+ }
@@ -7,3 +7,15 @@ export type GroupRun<T> = RowGroup & {
7
7
  rows: T[];
8
8
  };
9
9
  export declare function groupRuns<T>(rows: readonly T[], groupOf: (row: T) => RowGroup): GroupRun<T>[];
10
+ /**
11
+ * Rows grouped by a choice field's value (DataTable's `groupings`): one
12
+ * group per option, in the options' order, then any value the options
13
+ * don't name (in the order first met), then the rows with none under
14
+ * `emptyLabel`. A multi-value field (an array) puts the row under each of
15
+ * its values. Rows keep the server's order inside a group, and a group
16
+ * with no rows on this page never exists.
17
+ */
18
+ export declare function groupByValues<T>(rows: readonly T[], valueOf: (row: T) => string | readonly string[] | null | undefined, options: readonly {
19
+ value: string;
20
+ label: string;
21
+ }[], emptyLabel: string): GroupRun<T>[];
@@ -16,3 +16,40 @@ export function groupRuns(rows, groupOf) {
16
16
  }
17
17
  return runs;
18
18
  }
19
+ /**
20
+ * Rows grouped by a choice field's value (DataTable's `groupings`): one
21
+ * group per option, in the options' order, then any value the options
22
+ * don't name (in the order first met), then the rows with none under
23
+ * `emptyLabel`. A multi-value field (an array) puts the row under each of
24
+ * its values. Rows keep the server's order inside a group, and a group
25
+ * with no rows on this page never exists.
26
+ */
27
+ export function groupByValues(rows, valueOf, options, emptyLabel) {
28
+ const byKey = new Map();
29
+ const unknown = [];
30
+ const known = new Set(options.map((option) => option.value));
31
+ // The empty group's key can't collide with a value: values are non-empty.
32
+ const EMPTY = '';
33
+ for (const row of rows) {
34
+ const raw = valueOf(row);
35
+ const values = [...new Set((typeof raw === 'string' ? [raw] : (raw ?? [])).filter((value) => value !== ''))];
36
+ for (const value of values.length > 0 ? values : [EMPTY]) {
37
+ if (value !== EMPTY && !known.has(value) && !byKey.has(value))
38
+ unknown.push(value);
39
+ const group = byKey.get(value);
40
+ if (group)
41
+ group.push(row);
42
+ else
43
+ byKey.set(value, [row]);
44
+ }
45
+ }
46
+ const run = (key, label) => {
47
+ const groupRows = byKey.get(key);
48
+ return groupRows ? [{ key, label, rows: groupRows }] : [];
49
+ };
50
+ return [
51
+ ...options.flatMap((option) => run(option.value, option.label)),
52
+ ...unknown.flatMap((value) => run(value, value)),
53
+ ...run(EMPTY, emptyLabel),
54
+ ];
55
+ }
@@ -13,6 +13,13 @@ export type TableView<F extends string, M extends string> = {
13
13
  * Applies only while the view is active (see activeViewOf).
14
14
  */
15
15
  columns?: readonly string[];
16
+ /**
17
+ * The grouping this view turns on, for DataTable's `grouping`: a
18
+ * grouping's id, or '' for None. Pressing the view sets it; a view that
19
+ * leaves it out keeps whatever grouping is on, since a grouping is how
20
+ * the rows are read, not which rows they are.
21
+ */
22
+ group?: string;
16
23
  };
17
24
  export declare function serializeSort(next: Sort): string | null;
18
25
  export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
@@ -27,6 +27,7 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
27
27
  ...Object.fromEntries(filterNames.map((name) => [name, view.filters?.[name] ?? ''])),
28
28
  ...Object.fromEntries(multiFilterNames.map((name) => [name, view.multiFilters?.[name] ?? []])),
29
29
  sort: view.sort ? serializeSort(view.sort) : null,
30
+ ...(view.group === undefined ? {} : { group: view.group || null }),
30
31
  page: null,
31
32
  };
32
33
  }
@@ -7,6 +7,7 @@ export type LayoutStore = {
7
7
  export declare function createLayoutStore(getStorage: () => StorageLike | undefined): LayoutStore;
8
8
  /** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
9
9
  export declare function resolveLayout(layouts: readonly string[], fromUrl: string | null, remembered: string | null, defaultLayout: string): string;
10
+ export declare const browserStore: LayoutStore;
10
11
  export type TableLayoutOptions = {
11
12
  /** Every layout id the table has, the table's own `'table'` among them. */
12
13
  layouts: readonly string[];
@@ -43,7 +43,8 @@ export function resolveLayout(layouts, fromUrl, remembered, defaultLayout) {
43
43
  return remembered;
44
44
  return defaultLayout;
45
45
  }
46
- const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
46
+ // This device's remembered picks, shared with useTableUrlState's grouping.
47
+ export const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
47
48
  /**
48
49
  * `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
49
50
  * Setting one writes `?layout=` (replacing the history entry, as a filter
@@ -1,11 +1,21 @@
1
1
  import type { SortingState } from '../components/app/data-table.js';
2
2
  import { type TableView } from './table-url-changes.js';
3
- export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>): {
3
+ export type TableUrlStateOptions = {
4
+ /**
5
+ * Where the table's grouping (DataTable's `grouping`) is remembered on
6
+ * this device, one key per table (`"my-app:tasks:group"`), the way
7
+ * useTableLayout remembers a layout. Without one it lives only in the URL.
8
+ */
9
+ groupStorageKey?: string;
10
+ };
11
+ export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>, { groupStorageKey }?: TableUrlStateOptions): {
4
12
  page: number;
5
13
  sorting: SortingState;
6
14
  filters: Record<F, string>;
7
15
  multiFilters: Record<M, string[]>;
8
16
  activeView: V | null;
17
+ grouping: string;
18
+ setGrouping: (next: string) => void;
9
19
  setPage: (next: number) => void;
10
20
  setSorting: (next: SortingState) => void;
11
21
  setFilter: (name: F, value: string) => void;
@@ -1,5 +1,7 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
1
2
  import { useSearchParams } from 'react-router';
2
3
  import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
4
+ import { browserStore } from './use-table-layout.js';
3
5
  // A table's page, sort and filters, kept in the URL query string
4
6
  // (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
5
7
  // component state, so they survive going to the edit form and back, a
@@ -15,9 +17,22 @@ import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from
15
17
  // counts as active); pass that view's `columns` to DataTable. A view is
16
18
  // pressed with applyView().
17
19
  //
20
+ // `grouping` is DataTable's: a grouping's id, or '' for None. It's
21
+ // `?group=` in the URL, then the one remembered under `groupStorageKey`,
22
+ // then None. Like the sort it doesn't change which rows are shown, so it
23
+ // keeps the page and doesn't count toward the active view; a view that
24
+ // names a `group` sets it when pressed.
25
+ //
18
26
  // Every setter is one update(), and must stay one: see setFilters.
19
- export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
27
+ export function useTableUrlState(filterNames, multiFilterNames = [], views = {}, { groupStorageKey } = {}) {
20
28
  const [params, setParams] = useSearchParams();
29
+ const subscribe = useCallback((listener) => (groupStorageKey === undefined ? () => { } : browserStore.subscribe(groupStorageKey, listener)), [groupStorageKey]);
30
+ const rememberedGrouping = useSyncExternalStore(subscribe, () => (groupStorageKey === undefined ? null : browserStore.get(groupStorageKey)), () => null);
31
+ const grouping = params.get('group') ?? rememberedGrouping ?? '';
32
+ const rememberGrouping = (next) => {
33
+ if (groupStorageKey !== undefined)
34
+ browserStore.set(groupStorageKey, next);
35
+ };
21
36
  const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
22
37
  const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
23
38
  const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
@@ -33,6 +48,11 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
33
48
  filters,
34
49
  multiFilters,
35
50
  activeView,
51
+ grouping,
52
+ setGrouping: (next) => {
53
+ rememberGrouping(next);
54
+ update({ group: next || null });
55
+ },
36
56
  setPage: (next) => update({ page: next > 1 ? String(next) : null }),
37
57
  setSorting: (next) => update({ sort: serializeSort(next), page: null }),
38
58
  setFilter: (name, value) => update({ [name]: value, page: null }),
@@ -46,6 +66,10 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
46
66
  setMultiFilter: (name, values) => update({ [name]: values, page: null }),
47
67
  // Replaces the whole filter + sort state at once — a saved view. See
48
68
  // viewToUrlChanges() for why anything the view doesn't set is cleared.
49
- applyView: (view) => update(viewToUrlChanges(filterNames, multiFilterNames, view)),
69
+ applyView: (view) => {
70
+ if (view.group !== undefined)
71
+ rememberGrouping(view.group);
72
+ update(viewToUrlChanges(filterNames, multiFilterNames, view));
73
+ },
50
74
  };
51
75
  }
package/dist/index.d.ts CHANGED
@@ -6,10 +6,11 @@ export { pickSpinWheelIndex, type SpinWheelItem } from './components/app/spin-wh
6
6
  export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
7
7
  export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
8
8
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
9
- export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
9
+ export { DataTable, type DataTableCardLayout, type DataTableGrouping, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
10
10
  export { type RowGroup } from './components/app/table-groups.js';
11
11
  export { byDay, type ByDayOptions } from './lib/group-by-day.js';
12
12
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
13
+ export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
13
14
  export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
14
15
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
15
16
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
@@ -55,7 +56,7 @@ export type { AuthContextValue, AuthStatus, AuthUser, SignInResult } from './aut
55
56
  export type { AppError, Page, QuerySpec } from './api/contracts.js';
56
57
  export type { ErrorReportingOptions } from './error-reporting/reporter.js';
57
58
  export { createQueryClient } from './api/query-client.js';
58
- export { useTableUrlState } from './hooks/use-table-url-state.js';
59
+ export { useTableUrlState, type TableUrlStateOptions } from './hooks/use-table-url-state.js';
59
60
  export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
60
61
  export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, type SavedWidgetLayout, type WidgetLayout, type WidgetLayoutOptions, type WidgetLayoutStore, } from './hooks/use-widget-layout.js';
61
62
  export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { DataTable, } from './components/app/data-table.js';
14
14
  export {} from './components/app/table-groups.js';
15
15
  export { byDay } from './lib/group-by-day.js';
16
16
  export { EntityForm } from './components/app/entity-form.js';
17
+ export { KeyStats } from './components/app/key-stats.js';
17
18
  export { EntityView, } from './components/app/entity-view.js';
18
19
  export { editInPlace, } from './components/app/edit-in-place.js';
19
20
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.45.0",
3
+ "version": "4.46.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {