@tristan2828/ui-foundation 4.31.0 → 4.33.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/AGENTS.md +2 -1
- package/conventions/docs/add-an-entity.md +13 -2
- package/conventions/docs/cell-patterns.md +8 -0
- package/conventions/docs/design-language.md +97 -6
- package/dist/components/app/data-table.d.ts +21 -1
- package/dist/components/app/data-table.js +20 -4
- package/dist/components/app/list-editor-rows.d.ts +18 -0
- package/dist/components/app/list-editor-rows.js +23 -0
- package/dist/components/app/list-editor.d.ts +25 -3
- package/dist/components/app/list-editor.js +61 -5
- package/dist/components/app/markdown.d.ts +10 -2
- package/dist/components/app/markdown.js +80 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +19 -10
- package/dist/components/app/module-card.d.ts +53 -0
- package/dist/components/app/module-card.js +95 -0
- package/dist/components/app/prose-look.d.ts +26 -6
- package/dist/components/app/prose-look.js +24 -6
- package/dist/components/app/prose-table-scroll.d.ts +33 -0
- package/dist/components/app/prose-table-scroll.js +87 -0
- package/dist/components/app/rich-text-editor.d.ts +10 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-node-views.d.ts +6 -3
- package/dist/components/app/rich-text-node-views.js +108 -5
- package/dist/components/app/widget-card.js +13 -4
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +12 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/styles/index.css +14 -0
- package/styles/theme.css +5 -0
package/conventions/AGENTS.md
CHANGED
|
@@ -84,7 +84,8 @@ Every data view handles: loading, empty, error, and success. Use
|
|
|
84
84
|
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
|
|
85
85
|
and `EntityView` (one record) adds not found: a plain 404, never a retry.
|
|
86
86
|
A small widget on a hub page is a `WidgetCard`, which holds all four for
|
|
87
|
-
its own data, its error inside the card
|
|
87
|
+
its own data, its error inside the card; a hub's place (a module, with
|
|
88
|
+
one live number) is a `ModuleCard` in a `ModuleStrip`, the same way.
|
|
88
89
|
Every screen has one Playwright test per state, forced via MSW overrides
|
|
89
90
|
(`@tristan2828/ui-foundation/testing`).
|
|
90
91
|
|
|
@@ -152,7 +152,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
152
152
|
`<ListEditor>` inside a `FieldSet`/`FieldLegend` on the form, each row's
|
|
153
153
|
controls named by position ("Item 2 text"), a per-item `FieldError`
|
|
154
154
|
(a server 422 at `["body", "checklist", 2, "text"]` arrives as
|
|
155
|
-
`fieldErrors["checklist.2.text"]` and binds to that row)
|
|
155
|
+
`fieldErrors["checklist.2.text"]` and binds to that row). A list whose
|
|
156
|
+
items are rich (five or six fields each) and run to ten or more also
|
|
157
|
+
passes `summary={(i) => …}`, one line per item that opens to its
|
|
158
|
+
fields, one at a time, and `invalid={(i) => !!errors.<list>?.[i]}` so
|
|
159
|
+
an item the save refuses opens to show why. A list whose items repeat
|
|
160
|
+
with small changes passes
|
|
161
|
+
``onDuplicate={(i) => insert(i + 1, { ...getValues(`<list>.${i}`) })}``,
|
|
162
|
+
leaving out any saved id; the copy lands after its original and opens.
|
|
163
|
+
Then a summary
|
|
156
164
|
in the table, never the items (an item with a `reference` field
|
|
157
165
|
copies Pairings, below) — and a `computed` field copies
|
|
158
166
|
Progress (`checklistState`): `readOnly` in `openapi.yaml` and absent
|
|
@@ -438,7 +446,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
438
446
|
(`> [!NOTE]`) and links, links opening in a new tab; raw HTML shows
|
|
439
447
|
as the characters typed, never as markup. The app needs no Markdown dependency of its own. Plain
|
|
440
448
|
long text renders as text (`whitespace-pre-line` keeps its line
|
|
441
|
-
breaks).
|
|
449
|
+
breaks). Give `<Markdown>` and `RichTextEditor` a `headingLevel` for
|
|
450
|
+
where they sit: the default (3) in an `EntityView` section,
|
|
451
|
+
`headingLevel={2}` for a `RichTextEditor` on a form straight under
|
|
452
|
+
the page's `<h1>`.
|
|
442
453
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
443
454
|
names**, each a control that saves on its own (cell pattern 17,
|
|
444
455
|
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
@@ -388,6 +388,14 @@ cell: ({ getValue }) => {
|
|
|
388
388
|
- An API `format: decimal` arrives as a **string**. `Number()` is exact
|
|
389
389
|
enough to display (about 15 significant digits). Never add up money in
|
|
390
390
|
floats on the client: totals come from the backend.
|
|
391
|
+
- **A column's total goes in `DataTable`'s `totals`**:
|
|
392
|
+
``totals={data && { label: `Total · ${data.total} items`, cells: { price: usd(data.totals.price) } }}``.
|
|
393
|
+
One formatted value per column id, from the list endpoint beside
|
|
394
|
+
`total`, so it covers every matching row, not only the page. It renders
|
|
395
|
+
as a footer row: the label in a row header in the first visible column,
|
|
396
|
+
each value under its column in that column's alignment and tabular
|
|
397
|
+
figures, above a 2px border. A hidden column's value isn't shown, and
|
|
398
|
+
the row is hidden while loading, on error and when empty.
|
|
391
399
|
- **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
|
|
392
400
|
supports `'center'`, and a right-aligned cell under a left-aligned header
|
|
393
401
|
looks broken. Stay left-aligned until a real column needs
|
|
@@ -221,7 +221,7 @@ room that stays readable.
|
|
|
221
221
|
|
|
222
222
|
## Typography
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
Six roles cover every screen. Each is one class that sets size, line
|
|
225
225
|
height and weight together, from tokens in the package's `styles/theme.css`:
|
|
226
226
|
|
|
227
227
|
| Class | For | Size / weight |
|
|
@@ -231,12 +231,17 @@ height and weight together, from tokens in the package's `styles/theme.css`:
|
|
|
231
231
|
| `type-body` | running text, table cells, form text, the pagination summary | 14px / 400 |
|
|
232
232
|
| `type-label` | column headers, field labels, a small heading over a group | 14px / 500 |
|
|
233
233
|
| `type-caption` | counts, metadata, helper text under a field | 12px / 400 |
|
|
234
|
+
| `type-numeric` | the one live number on a card (`ModuleCard`'s count), in tabular figures | 22px / 500 |
|
|
234
235
|
|
|
235
236
|
- **Use a role instead of size and weight classes,** not as well as them.
|
|
236
237
|
`type-page-title` replaces `text-lg font-semibold`. Combining a role with
|
|
237
238
|
`text-sm`, `font-medium` or `leading-*` makes two classes set the same
|
|
238
239
|
property, and which one wins depends on the order of rules in the
|
|
239
240
|
stylesheet, not on the order in `className`.
|
|
241
|
+
- **`type-numeric` sets tabular figures** (`font-variant-numeric:
|
|
242
|
+
tabular-nums`), so counts line up from card to card and don't change
|
|
243
|
+
width as they tick. A number in running text or a table cell keeps its
|
|
244
|
+
row's role; add `tabular-nums` there if a column of them should align.
|
|
240
245
|
- **Colour stays separate:** `type-caption text-muted-foreground`. A role
|
|
241
246
|
never sets a colour, so any text token pairs with it.
|
|
242
247
|
- **Why `type-*` and not `text-*`:** `cn` (tailwind-merge) treats an
|
|
@@ -264,6 +269,20 @@ In text with a band, the rest (paragraphs, lists, tables, callouts,
|
|
|
264
269
|
words in the band rather than its edge; text with no band stays flush. The level reads at a glance: two bands of
|
|
265
270
|
different strength, then a label.
|
|
266
271
|
|
|
272
|
+
Headings in the text sit under where the text is in the page's outline.
|
|
273
|
+
`headingLevel` on `<Markdown>` and `RichTextEditor` is the HTML level `#`
|
|
274
|
+
renders as: 2 straight under the page's `<h1>` (a form, or a page that is
|
|
275
|
+
the note), 3 under a section's `<h2>` (the default). `##` is one below it
|
|
276
|
+
and `###` two below, never past `<h6>`, so the outline never skips a
|
|
277
|
+
level (axe's heading-order). Only the element moves: each level keeps its
|
|
278
|
+
look, and the editor saves `#` as `#`.
|
|
279
|
+
|
|
280
|
+
A table wider than the text scrolls sideways. While it does, its box is a
|
|
281
|
+
tab stop and a region named "Table, scrolls sideways: " and its first
|
|
282
|
+
columns ("Item, Size, Colour, …"), so two such tables keep distinct
|
|
283
|
+
names; the arrow keys scroll it (in the editor Home and End too), with
|
|
284
|
+
the `ring` focus ring. A table that fits gets no tab stop and no region.
|
|
285
|
+
|
|
267
286
|
The same text has four colour tokens of its own: `--link` (`text-link`), a
|
|
268
287
|
link's colour, blue as well as underlined (5.59:1 on white, 8.90:1 on the
|
|
269
288
|
dark page); `--heading-band` (`bg-heading-band`), the `#` band, one step
|
|
@@ -294,13 +313,18 @@ set, not a `DataTable` prop:
|
|
|
294
313
|
|
|
295
314
|
| `data-density` | Changes | Table cell padding (vertical) | Header height | For |
|
|
296
315
|
|---|---|---|---|---|
|
|
297
|
-
| `compact` | table rows
|
|
316
|
+
| `compact` | table rows, and a hub's cards (`WidgetCard`, `WidgetList` rows, `ModuleCard`) | 4px | 32px | wide tables people scan, many rows per screen; a dense hub |
|
|
298
317
|
| *(unset)* | — | 8px | 40px | the default |
|
|
299
318
|
| `comfortable` | table rows only | 12px | 48px | short tables, or rows people read rather than scan |
|
|
300
319
|
| `large` | **everything**: type, spacing, control heights, icons, the sidebar, table rows | 12px | 56px | a screen read from a distance: a TV used as the desktop monitor, with a mouse and keyboard |
|
|
301
320
|
|
|
302
321
|
- `compact` and `comfortable` change padding only. Text keeps
|
|
303
322
|
`type-body`, so compact never drops below a readable size.
|
|
323
|
+
- On a hub, `compact` takes a `WidgetCard`'s padding from 16px to 12px,
|
|
324
|
+
a `WidgetList` row from 36px to 32px (6px around its line) and a
|
|
325
|
+
`ModuleCard`'s band and body by 4px each. On a phone a compact row is
|
|
326
|
+
44px instead, its link filling that height, so it stays a finger's
|
|
327
|
+
target.
|
|
304
328
|
- `large` grows the whole UI by about a quarter: body text 14 → 18px,
|
|
305
329
|
controls 32 → 40px tall, icons 16 → 20px, the sidebar 256 → 320px.
|
|
306
330
|
Breakpoints and `max-w-*` containers stay where they are, which is
|
|
@@ -308,7 +332,8 @@ set, not a `DataTable` prop:
|
|
|
308
332
|
a wide screen can fall into the phone layout).
|
|
309
333
|
- For `large`, set it on **`<html>`**. Menus, selects, dialogs and
|
|
310
334
|
tooltips are portaled to `<body>`, so a wrapper `<div>` doesn't reach
|
|
311
|
-
them. `compact` and `comfortable` only touch tables, so a
|
|
335
|
+
them. `compact` and `comfortable` only touch tables and hub cards, so a
|
|
336
|
+
wrapper is fine.
|
|
312
337
|
- Pick **one density per screen**. Two tables at different densities side
|
|
313
338
|
by side look like a mistake.
|
|
314
339
|
- Size things the way the package does and `large` reaches them for free:
|
|
@@ -319,11 +344,17 @@ set, not a `DataTable` prop:
|
|
|
319
344
|
- Under the hood, the table primitive's cells read `--table-cell-px`,
|
|
320
345
|
`--table-cell-py` and `--table-head-height`. `large` also redefines
|
|
321
346
|
Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
|
|
322
|
-
roles and `--control-text-sm` (the small Button and Toggle).
|
|
347
|
+
roles and `--control-text-sm` (the small Button and Toggle).
|
|
348
|
+
`DataTable`'s totals row reads the body cells' tokens, so it is a row's
|
|
349
|
+
height at every density. Each
|
|
323
350
|
default is the value it always had, so a screen without `data-density`
|
|
324
|
-
doesn't change. The selectors are in `styles/theme.css`.
|
|
351
|
+
doesn't change. The selectors are in `styles/theme.css`. A composite
|
|
352
|
+
reaches `compact` with the `density-compact:` variant (`styles/index.css`:
|
|
353
|
+
any ancestor with `data-density="compact"`), the way `WidgetCard` and
|
|
354
|
+
`ModuleCard` do; an app's own card can use it too. Storybook's
|
|
325
355
|
Density toolbar shows any story at a density, and `patterns/Density`
|
|
326
|
-
shows the primitives, `DataTable
|
|
356
|
+
shows the primitives, `DataTable`, the sidebar and the hub cards at
|
|
357
|
+
each. Its checks
|
|
327
358
|
measure that every step changes what it should, and that the default
|
|
328
359
|
keeps today's sizes.
|
|
329
360
|
|
|
@@ -398,6 +429,66 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
|
|
|
398
429
|
one in each state; its checks run axe and the token and glyph checks in
|
|
399
430
|
both themes and at `large`.
|
|
400
431
|
|
|
432
|
+
## Module cards (a hub's places)
|
|
433
|
+
|
|
434
|
+
Above its widgets, a hub shows the app's places: its modules, and the
|
|
435
|
+
sites outside it that it links to. Each is a `ModuleCard` in a
|
|
436
|
+
`ModuleStrip`: a small card that names the place, shows one live number,
|
|
437
|
+
and offers one action.
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
const { data, isLoading, error, refetch } = useProjectsSummaryQuery()
|
|
441
|
+
|
|
442
|
+
<ModuleStrip aria-label="Places">
|
|
443
|
+
<ModuleCard
|
|
444
|
+
title="Projects"
|
|
445
|
+
icon={ListTodoIcon}
|
|
446
|
+
category={1}
|
|
447
|
+
to="/projects"
|
|
448
|
+
value={data?.openCount}
|
|
449
|
+
note="open"
|
|
450
|
+
action={{ to: '/projects?view=today', label: 'Today' }}
|
|
451
|
+
isLoading={isLoading}
|
|
452
|
+
error={error}
|
|
453
|
+
onRetry={() => refetch()}
|
|
454
|
+
/>
|
|
455
|
+
<ModuleCard title="Recipes" icon={RecipesMark} category={6} href="https://recipes.example.com" note="Opens its own app" />
|
|
456
|
+
</ModuleStrip>
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **The band names the place and links to it.** The icon takes the
|
|
460
|
+
place's categorical slot (`category`, 1–12), and the band behind the
|
|
461
|
+
icon and the name is a 10% wash of the same slot over the card, with
|
|
462
|
+
`foreground` text on it (axe measures every slot's wash in both themes).
|
|
463
|
+
Give each place the slot it has elsewhere in the app (its sidebar entry,
|
|
464
|
+
its badges), so a place is one colour everywhere. Without a `category`
|
|
465
|
+
the icon is muted and the band plain.
|
|
466
|
+
- **One number, with a short note.** `value` is the one count that says
|
|
467
|
+
what's live there ("5" open, "2" due today), in `type-numeric`; `note`
|
|
468
|
+
is a few words under it. A card with no `value` shows no number, and
|
|
469
|
+
that is not an empty state: a place outside the app usually has none,
|
|
470
|
+
and its note says what it is ("Opens its own app").
|
|
471
|
+
- **One action** at the foot: the thing most often done next ("Today",
|
|
472
|
+
"Start", "Add one"). It's described by the place's name, so several
|
|
473
|
+
"Open" links on one page stay distinct.
|
|
474
|
+
- **A place outside the app** takes `href` instead of `to` (an action
|
|
475
|
+
can too): it opens in a new tab with the external-link mark. Its icon
|
|
476
|
+
can be the service's own mark ("Logos and brand marks" above).
|
|
477
|
+
- **States are the number's.** While loading, a skeleton the number's
|
|
478
|
+
size (the note stays, it doesn't load); on an error, "Couldn't load"
|
|
479
|
+
and Retry inside the card, so one failing place never hides the
|
|
480
|
+
others. The band and the action work in every state.
|
|
481
|
+
- **The strip** is a navigation landmark (`aria-label` names it) of
|
|
482
|
+
equal columns, 170px or wider, that wrap: six to eight across a
|
|
483
|
+
desktop. On a phone it's three columns of smaller tiles with no action,
|
|
484
|
+
each tile one link to its place (at least 44px tall); Retry stays its
|
|
485
|
+
own button above it. Six places make the phone's 3 × 2 grid; past
|
|
486
|
+
that, put the rest behind one tile ("More") rather than a third row.
|
|
487
|
+
- Storybook's `app/ModuleCard` shows a hub with a strip and a widget,
|
|
488
|
+
every state, and both at `compact`; its checks run axe and the token and
|
|
489
|
+
glyph checks in both themes and at `large`, and measure the strip at a
|
|
490
|
+
desktop and a phone width.
|
|
491
|
+
|
|
401
492
|
## Notices
|
|
402
493
|
|
|
403
494
|
A `Notice` says, across the page, a state that decides what you do with
|
|
@@ -97,6 +97,26 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
|
|
|
97
97
|
* Colour isn't the only signal: say why in the row too (a badge, a word).
|
|
98
98
|
*/
|
|
99
99
|
highlightRow?: (row: TData) => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* A totals row under the columns it adds up, the way a spreadsheet's
|
|
102
|
+
* is: `label` in the first visible column ("Total · 18 items"), then
|
|
103
|
+
* each of `cells` under the column with that id, in its alignment. The
|
|
104
|
+
* values come from the server, for every row the filters match, never
|
|
105
|
+
* summed from the page on the client. Shown only with rows: never while
|
|
106
|
+
* loading, on error or empty. Leave undefined (or null, before the data
|
|
107
|
+
* has come) for no row.
|
|
108
|
+
*/
|
|
109
|
+
totals?: DataTableTotals | null;
|
|
100
110
|
};
|
|
101
111
|
export type DataTableWidth = 'full' | 'content';
|
|
102
|
-
export
|
|
112
|
+
export type DataTableTotals = {
|
|
113
|
+
/** Goes in the first visible column, as the row's header. */
|
|
114
|
+
label: ReactNode;
|
|
115
|
+
/**
|
|
116
|
+
* One value per column id, already formatted. A column left out, or
|
|
117
|
+
* hidden by `visibleColumns`, gets an empty cell or none. The first
|
|
118
|
+
* visible column holds the label, so a value for it isn't shown.
|
|
119
|
+
*/
|
|
120
|
+
cells?: Partial<Record<string, ReactNode>>;
|
|
121
|
+
};
|
|
122
|
+
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, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
@@ -23,7 +23,7 @@ import { EditInPlaceContext } from './edit-in-place.js';
|
|
|
23
23
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
24
24
|
import { LeaveGuard } from './editable-value.js';
|
|
25
25
|
import { groupRuns } from './table-groups.js';
|
|
26
|
-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
|
|
26
|
+
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '../ui/table.js';
|
|
27
27
|
// A column def's id as TanStack resolves it (constructColumn): `id`, then
|
|
28
28
|
// `accessorKey` with dots as underscores, then a string header. Group
|
|
29
29
|
// columns are looked through to their leaves, which visibility is keyed by.
|
|
@@ -59,6 +59,9 @@ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-h
|
|
|
59
59
|
// so lighting it too read as a second selected thing. Only the first
|
|
60
60
|
// column marks the hovered row.
|
|
61
61
|
const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
|
|
62
|
+
// The totals row's label, pinned with the first column (`totals`): the
|
|
63
|
+
// same opaque cell, with no hover, since the row isn't one to pick.
|
|
64
|
+
const PINNED_TOTALS_LABEL_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS}`;
|
|
62
65
|
// A pinned first column's content, capped below md at 45% of the table's
|
|
63
66
|
// scroll area (`cqw`: the container is an inline-size query container)
|
|
64
67
|
// and wrapping, so a long title can't widen the column past the screen
|
|
@@ -82,6 +85,15 @@ const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
|
|
|
82
85
|
// Inside, muted text takes the darker grey the wash needs (theme.css).
|
|
83
86
|
const HIGHLIGHTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-(--table-highlight) ' +
|
|
84
87
|
'[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--info-text)]';
|
|
88
|
+
// `totals`: a footer row the way a spreadsheet's totals are, set apart
|
|
89
|
+
// from the rows by a border twice a row divider's width and medium type,
|
|
90
|
+
// on the page's background (table.tsx's footer tint is translucent, so a
|
|
91
|
+
// pinned cell over it couldn't match without bleed-through). Its cells
|
|
92
|
+
// read the body's density tokens, so it's a row's height, not the
|
|
93
|
+
// header's; tabular figures line its numbers up with the column's.
|
|
94
|
+
const TOTALS_FOOTER_CLASS = 'border-t-2 border-border bg-background tabular-nums';
|
|
95
|
+
// Its label is a row header (<th scope="row">), padded as a body cell is.
|
|
96
|
+
const TOTALS_LABEL_CLASS = 'h-auto py-(--table-cell-py)';
|
|
85
97
|
// `header={false}`: the header row stays in the table, so every cell keeps
|
|
86
98
|
// its column header for a screen reader, but takes no room: each header
|
|
87
99
|
// is its name in an sr-only span, in a cell with no height or padding.
|
|
@@ -157,7 +169,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
157
169
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
158
170
|
return { barRef, isOverflowing, scrollWidth };
|
|
159
171
|
}
|
|
160
|
-
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, }) {
|
|
172
|
+
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, }) {
|
|
161
173
|
// Editing in place in the rows (cell pattern 18, "In a table's rows"):
|
|
162
174
|
// a cell that renders an <EditableValue> edits where it's shown, one at
|
|
163
175
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
@@ -216,7 +228,8 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
216
228
|
const containerRef = useRef(null);
|
|
217
229
|
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
|
|
218
230
|
const rows = table.getRowModel().rows;
|
|
219
|
-
const
|
|
231
|
+
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
232
|
+
const visibleColumnCount = visibleLeafColumns.length;
|
|
220
233
|
// One body row, the same in a plain table and a grouped one.
|
|
221
234
|
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, cell.column.columnDef.meta?.align === 'center' && 'text-center'), 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));
|
|
222
235
|
const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
|
|
@@ -247,6 +260,9 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
247
260
|
: sortDirection === 'desc'
|
|
248
261
|
? 'descending'
|
|
249
262
|
: '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));
|
|
250
|
-
}) }, 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) }))
|
|
263
|
+
}) }, 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) => {
|
|
264
|
+
const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, column.columnDef.meta?.align === 'center' && 'text-center');
|
|
265
|
+
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));
|
|
266
|
+
}) }) }))] }), pinFirstColumn && isOverflowing && (_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, {}) })] })] })] }))] }));
|
|
251
267
|
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
|
|
252
268
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open item once the list's ids have changed: the item that just
|
|
3
|
+
* appeared, when an add or a duplicate asked for it (`opening`), else the
|
|
4
|
+
* one that was open, if it's still there. A list replaced whole (a form's
|
|
5
|
+
* `reset`) gives every item a new id, and nothing asked, so nothing opens.
|
|
6
|
+
*/
|
|
7
|
+
export declare function followOpenItem({ before, after, open, opening, }: {
|
|
8
|
+
before: readonly string[];
|
|
9
|
+
after: readonly string[];
|
|
10
|
+
open: string | null;
|
|
11
|
+
opening: boolean;
|
|
12
|
+
}): string | null;
|
|
13
|
+
/** Whether an item shows its fields: it's the open one, or it has an error to show. */
|
|
14
|
+
export declare function isItemOpen({ id, open, invalid }: {
|
|
15
|
+
id: string;
|
|
16
|
+
open: string | null;
|
|
17
|
+
invalid: boolean;
|
|
18
|
+
}): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Which of ListEditor's items are open, when its items show as summary
|
|
2
|
+
// lines (`summary`, #222). One item is open at a time, held by its stable
|
|
3
|
+
// id (useFieldArray's `field.id`), so it stays open while rows move and
|
|
4
|
+
// closes when it's removed. An item that was just added or duplicated
|
|
5
|
+
// opens, and one with a validation error is open whatever else is.
|
|
6
|
+
/**
|
|
7
|
+
* The open item once the list's ids have changed: the item that just
|
|
8
|
+
* appeared, when an add or a duplicate asked for it (`opening`), else the
|
|
9
|
+
* one that was open, if it's still there. A list replaced whole (a form's
|
|
10
|
+
* `reset`) gives every item a new id, and nothing asked, so nothing opens.
|
|
11
|
+
*/
|
|
12
|
+
export function followOpenItem({ before, after, open, opening, }) {
|
|
13
|
+
if (opening) {
|
|
14
|
+
const added = after.find((id) => !before.includes(id));
|
|
15
|
+
if (added !== undefined)
|
|
16
|
+
return added;
|
|
17
|
+
}
|
|
18
|
+
return open !== null && after.includes(open) ? open : null;
|
|
19
|
+
}
|
|
20
|
+
/** Whether an item shows its fields: it's the open one, or it has an error to show. */
|
|
21
|
+
export function isItemOpen({ id, open, invalid }) {
|
|
22
|
+
return invalid || id === open;
|
|
23
|
+
}
|
|
@@ -1,25 +1,47 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export declare function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
|
|
3
3
|
/** One entry per item, with a stable key (useFieldArray's `fields`). */
|
|
4
4
|
items: readonly {
|
|
5
5
|
id: string;
|
|
6
6
|
}[];
|
|
7
7
|
/** The item's own fields, for the item at this index. */
|
|
8
8
|
renderItem: (index: number) => ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Shows each item as one line, this text (e.g. "Squat · 3 × 8 at 60 kg"),
|
|
11
|
+
* that opens to its `renderItem` fields on a click, Enter or Space. One
|
|
12
|
+
* item is open at a time; one just added or duplicated opens. For a long
|
|
13
|
+
* list of rich items. Without it, every item shows its fields.
|
|
14
|
+
*/
|
|
15
|
+
summary?: (index: number) => ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* With `summary`: the item at this index has an error to show, e.g.
|
|
18
|
+
* (i) => !!errors.steps?.[i]. It opens, beside the open one, until the
|
|
19
|
+
* error clears, so a refused save never hides why. react-hook-form's
|
|
20
|
+
* focus on the first error then makes that item the open one.
|
|
21
|
+
*/
|
|
22
|
+
invalid?: (index: number) => boolean;
|
|
9
23
|
/** The Add button's action. Not needed with `add`. */
|
|
10
24
|
onAdd?: () => void;
|
|
11
25
|
onRemove: (index: number) => void;
|
|
12
26
|
onMove: (from: number, to: number) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Puts a Duplicate button beside Remove. The app inserts the copy right
|
|
29
|
+
* after the original, e.g. (i) => insert(i + 1, { ...getValues(`steps.${i}`) }),
|
|
30
|
+
* leaving out anything a copy mustn't share (a saved id). With `summary`,
|
|
31
|
+
* the copy opens. Disabled once `max` is reached, as Add is.
|
|
32
|
+
*/
|
|
33
|
+
onDuplicate?: (index: number) => void;
|
|
13
34
|
/**
|
|
14
35
|
* How the row buttons name an item for a screen reader, e.g.
|
|
15
36
|
* (i) => `item ${i + 1}` gives "Move item 2 up", "Remove item 2".
|
|
37
|
+
* A summary line is named by it too: "item 2: Squat · 3 × 8 at 60 kg".
|
|
16
38
|
*/
|
|
17
39
|
itemName: (index: number) => string;
|
|
18
40
|
/** The Add button's text, e.g. "Add item". */
|
|
19
41
|
addLabel: string;
|
|
20
42
|
/** Shown instead of the list when it's empty. */
|
|
21
43
|
emptyText?: string;
|
|
22
|
-
/** The most items allowed; Add
|
|
44
|
+
/** The most items allowed; Add and Duplicate are disabled once reached. */
|
|
23
45
|
max?: number;
|
|
24
46
|
/** In place of the Add button: what adds an item (editing in place's Add box). */
|
|
25
47
|
add?: ReactNode;
|
|
@@ -1,10 +1,66 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Edits a list of sub-records inside a parent's form: one row per item,
|
|
3
|
+
// each with move up / move down / remove, and an Add button below. The one
|
|
4
|
+
// control for every "sub-records" section of an entity plan
|
|
5
|
+
// (conventions/docs/entity-plan-template.md): Widget's checklist, a task's
|
|
6
|
+
// checklist and links.
|
|
7
|
+
//
|
|
8
|
+
// It owns the row chrome only. Each item's fields are the app's (rendered
|
|
9
|
+
// by `renderItem`), and so is the state: pass react-hook-form's
|
|
10
|
+
// useFieldArray `fields`, `append`, `remove` and `move`, so the items save
|
|
11
|
+
// with the parent's form and their errors bind like any other field.
|
|
12
|
+
//
|
|
13
|
+
// A long list of rich items (#222) can show each item as one line instead
|
|
14
|
+
// (`summary`): the line is a button that opens the item to its fields,
|
|
15
|
+
// one item at a time (list-editor-rows.ts), with the row buttons beside
|
|
16
|
+
// it. And `onDuplicate` puts a Duplicate button beside Remove, for lists
|
|
17
|
+
// whose items repeat with small changes. Without either, every item shows
|
|
18
|
+
// its fields, as before.
|
|
19
|
+
//
|
|
20
|
+
// Editing a list in place on a view (editable-list.tsx) is built on it
|
|
21
|
+
// too, so the rows and their buttons match the form's: there, `add` is the
|
|
22
|
+
// in-place Add box, and `disabled` holds the buttons while a change saves.
|
|
23
|
+
import { useState } from 'react';
|
|
24
|
+
import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon, CopyIcon, PlusIcon, Trash2Icon } from 'lucide-react';
|
|
3
25
|
import { Button } from '../ui/button.js';
|
|
4
|
-
|
|
26
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
|
|
27
|
+
import { followOpenItem, isItemOpen } from './list-editor-rows.js';
|
|
28
|
+
export function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
|
|
5
29
|
const atMax = max !== undefined && items.length >= max;
|
|
30
|
+
// The open item, by id. When the ids change (an add, a move, a remove),
|
|
31
|
+
// it follows them here, in the render that shows the change, so a new
|
|
32
|
+
// item's fields are already there when react-hook-form focuses them.
|
|
33
|
+
const ids = items.map((item) => item.id);
|
|
34
|
+
const [open, setOpen] = useState(null);
|
|
35
|
+
const [opening, setOpening] = useState(false);
|
|
36
|
+
const [seen, setSeen] = useState(ids);
|
|
37
|
+
if (seen.join('\n') !== ids.join('\n')) {
|
|
38
|
+
setSeen(ids);
|
|
39
|
+
setOpen(followOpenItem({ before: seen, after: ids, open, opening }));
|
|
40
|
+
setOpening(false);
|
|
41
|
+
}
|
|
42
|
+
// An add or a duplicate asks for the item it makes to open. The app's
|
|
43
|
+
// action runs in the same click, so its new id arrives with the ask.
|
|
44
|
+
const addItem = () => {
|
|
45
|
+
setOpening(true);
|
|
46
|
+
onAdd?.();
|
|
47
|
+
};
|
|
48
|
+
const duplicate = (index) => {
|
|
49
|
+
setOpening(true);
|
|
50
|
+
onDuplicate?.(index);
|
|
51
|
+
};
|
|
52
|
+
const rowButtons = (index, name) => (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} up`, disabled: disabled || index === 0, onClick: () => onMove(index, index - 1), children: _jsx(ArrowUpIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} down`, disabled: disabled || index === items.length - 1, onClick: () => onMove(index, index + 1), children: _jsx(ArrowDownIcon, {}) }), onDuplicate && (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Duplicate ${name}`, disabled: disabled || atMax, onClick: () => duplicate(index), children: _jsx(CopyIcon, {}) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }));
|
|
6
53
|
return (_jsxs("div", { className: "flex flex-col gap-2", children: [items.length === 0 ? (_jsx("p", { className: "type-caption text-muted-foreground", children: emptyText })) : (_jsx("ul", { "aria-label": ariaLabel, className: "flex flex-col gap-2", children: items.map((item, index) => {
|
|
7
54
|
const name = itemName(index);
|
|
8
|
-
|
|
9
|
-
|
|
55
|
+
if (!summary) {
|
|
56
|
+
return (_jsxs("li", { className: "flex items-start gap-1", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-start gap-2", children: renderItem(index) }), rowButtons(index, name)] }, item.id));
|
|
57
|
+
}
|
|
58
|
+
// A summary line: the whole line is the button (named by the
|
|
59
|
+
// item's name, then its summary), the row buttons beside it,
|
|
60
|
+
// and the fields under it while it's open. Clicking the line
|
|
61
|
+
// again closes it; an item with an error stays open. Going into
|
|
62
|
+
// an item's fields makes it the open one, so one opened by its
|
|
63
|
+
// error doesn't close under the caret once the error clears.
|
|
64
|
+
return (_jsx("li", { children: _jsxs(Collapsible, { open: isItemOpen({ id: item.id, open, invalid: invalid?.(index) ?? false }), onOpenChange: (next) => setOpen((current) => (next ? item.id : current === item.id ? null : current)), className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-start gap-1", children: [_jsxs(CollapsibleTrigger, { className: "group/summary flex min-h-7 min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1 text-left type-body text-foreground outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx(ChevronRightIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/summary:rotate-90 motion-reduce:transition-none" }), _jsxs("span", { className: "sr-only", children: [name, ": "] }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: summary(index) })] }), rowButtons(index, name)] }), _jsx(CollapsibleContent, { className: "flex min-w-0 items-start gap-2 pl-7", onFocus: () => setOpen(item.id), children: renderItem(index) })] }) }, item.id));
|
|
65
|
+
}) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
|
|
10
66
|
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
import { type HeadingLevel } from './prose-look.js';
|
|
2
3
|
export type MarkdownProps = {
|
|
3
4
|
/** The Markdown source. Raw HTML in it is shown as text, never rendered. */
|
|
4
5
|
children: string;
|
|
6
|
+
/**
|
|
7
|
+
* The HTML level the text's `#` renders as, where the text sits in the
|
|
8
|
+
* page's outline: 2 straight under the page's <h1>, 3 (the default)
|
|
9
|
+
* under a section's <h2>. `##` is one below it, `###` two below, never
|
|
10
|
+
* past <h6>. Only the element changes: each level looks the same.
|
|
11
|
+
*/
|
|
12
|
+
headingLevel?: HeadingLevel;
|
|
5
13
|
className?: string;
|
|
6
14
|
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
7
|
-
export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
15
|
+
export declare function Markdown({ children, headingLevel, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|