@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.
- package/conventions/docs/add-an-entity.md +19 -10
- package/conventions/docs/cell-patterns.md +111 -32
- package/conventions/docs/design-language.md +44 -19
- package/dist/components/app/data-table.d.ts +61 -4
- package/dist/components/app/data-table.js +217 -17
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +5 -3
- package/dist/components/app/key-stats.d.ts +14 -0
- package/dist/components/app/key-stats.js +6 -0
- package/dist/components/app/table-groups.d.ts +12 -0
- package/dist/components/app/table-groups.js +37 -0
- package/dist/hooks/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +1 -0
- package/dist/hooks/use-table-layout.d.ts +1 -0
- package/dist/hooks/use-table-layout.js +2 -1
- package/dist/hooks/use-table-url-state.d.ts +11 -1
- package/dist/hooks/use-table-url-state.js +26 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -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–
|
|
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–
|
|
14
|
-
- Step 7: Register routes · lines
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
19
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
20
|
-
- [Deleting](#deleting) · lines
|
|
21
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
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
|
|
7
|
-
- [1. Plain text](#1-plain-text) · lines
|
|
8
|
-
- [2. Icon + label](#2-icon--label) · lines
|
|
9
|
-
- [3. Icon in a badge](#3-icon-in-a-badge) · lines
|
|
10
|
-
- [4. Icon only, label in a tooltip](#4-icon-only-label-in-a-tooltip) · lines
|
|
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
|
|
12
|
-
- [5. Enum → tone-mapped badge](#5-enum--tone-mapped-badge) · lines
|
|
13
|
-
- [6. Multi-value enum, clustered](#6-multi-value-enum-clustered) · lines
|
|
14
|
-
- [7. Link as an outline button](#7-link-as-an-outline-button) · lines
|
|
15
|
-
- [8. Date, relative](#8-date-relative) · lines
|
|
16
|
-
- [9. Ordinal scale (bounded integer)](#9-ordinal-scale-bounded-integer) · lines
|
|
17
|
-
- [Two variants the package draws: a step bar and a star](#two-variants-the-package-draws-a-step-bar-and-a-star) · lines
|
|
18
|
-
- [10. Summary in the cell, detail in a popover](#10-summary-in-the-cell-detail-in-a-popover) · lines
|
|
19
|
-
- [A second example: what a row depends on](#a-second-example-what-a-row-depends-on) · lines
|
|
20
|
-
- [11. Number or currency — Unproven](#11-number-or-currency--unproven) · lines
|
|
21
|
-
- [12. Boolean](#12-boolean) · lines
|
|
22
|
-
- [13. Progress bar — Unproven](#13-progress-bar--unproven) · lines
|
|
23
|
-
- [The compact form: `ProgressRing`](#the-compact-form-progressring) · lines
|
|
24
|
-
- [14. Avatar and name — Unproven](#14-avatar-and-name--unproven) · lines
|
|
25
|
-
- [15. Yes/no you flip in the row](#15-yesno-you-flip-in-the-row) · lines
|
|
26
|
-
- [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines
|
|
27
|
-
- [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines
|
|
28
|
-
- [17. Quick action on the view](#17-quick-action-on-the-view) · lines
|
|
29
|
-
- [18. Editable value](#18-editable-value) · lines
|
|
30
|
-
- [A list of sub-items](#a-list-of-sub-items) · lines
|
|
31
|
-
- [In a table's rows](#in-a-tables-rows) · lines
|
|
32
|
-
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines
|
|
33
|
-
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines
|
|
34
|
-
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines
|
|
35
|
-
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines
|
|
36
|
-
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines
|
|
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
|
-
- **
|
|
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
|
|
7
|
-
- [The three badge styles](#the-three-badge-styles) · lines
|
|
8
|
-
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines
|
|
9
|
-
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines
|
|
10
|
-
- [Logos and brand marks](#logos-and-brand-marks) · lines
|
|
11
|
-
- [Categorical colour](#categorical-colour) · lines
|
|
12
|
-
- [Typography](#typography) · lines
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [A list as a board](#a-list-as-a-board) · lines
|
|
20
|
-
- [A list as cards](#a-list-as-cards) · lines
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
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
|
|
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"
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
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(
|
|
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))) }),
|
|
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
|
-
|
|
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:
|
|
330
|
-
?
|
|
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; `
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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.
|
|
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": {
|