@tristan2828/ui-foundation 4.39.0 → 4.41.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/cell-patterns.md +41 -10
- package/conventions/docs/design-language.md +63 -14
- package/dist/components/app/artwork.d.ts +43 -0
- package/dist/components/app/artwork.js +43 -0
- package/dist/components/app/data-table.d.ts +49 -1
- package/dist/components/app/data-table.js +64 -6
- package/dist/components/app/edit-in-place.d.ts +37 -12
- package/dist/components/app/editable-list.js +33 -16
- package/dist/components/app/editable-value.js +1 -1
- package/dist/hooks/use-table-layout.d.ts +31 -0
- package/dist/hooks/use-table-layout.js +67 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
- [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
|
|
27
27
|
- [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
|
|
28
28
|
- [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–759
|
|
29
|
-
- [18. Editable value](#18-editable-value) · lines 761–
|
|
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
|
|
29
|
+
- [18. Editable value](#18-editable-value) · lines 761–1207
|
|
30
|
+
- [A list of sub-items](#a-list-of-sub-items) · lines 1023–1108
|
|
31
|
+
- [In a table's rows](#in-a-tables-rows) · lines 1110–1195
|
|
32
|
+
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1197–1207
|
|
33
|
+
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1209–1275
|
|
34
|
+
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1277–1341
|
|
35
|
+
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1343–1408
|
|
36
|
+
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1410–1438
|
|
37
37
|
|
|
38
38
|
<!-- /index -->
|
|
39
39
|
|
|
@@ -781,8 +781,11 @@ the form stay.
|
|
|
781
781
|
**At rest: says it's editable without shouting.** The value reads as it
|
|
782
782
|
always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
|
|
783
783
|
a real button ("Edit <label>"), so Tab reaches it and Enter opens the
|
|
784
|
-
field, and its focus ring tints the value too.
|
|
785
|
-
|
|
784
|
+
field, and its focus ring tints the value too. Muted text inside it (the
|
|
785
|
+
view's "Not set", a "No caption", a done item) turns `text-foreground`
|
|
786
|
+
on that tint, since muted on `bg-muted` is under 4.5:1; the package does
|
|
787
|
+
it, so an app just uses `text-muted-foreground` as anywhere else. On a
|
|
788
|
+
touch screen the pencil always shows. A click anywhere on the value opens it, except on a
|
|
786
789
|
link inside it (a reference to a record with a view, a link in the
|
|
787
790
|
notes), which still navigates.
|
|
788
791
|
|
|
@@ -1056,6 +1059,14 @@ Checklist is the reference (`checklist` in `widgetEdits`). It's built on
|
|
|
1056
1059
|
option is still inside the row, and Enter in the row's text box or
|
|
1057
1060
|
leaving the row saves the item, ids only on the wire. A new item starts
|
|
1058
1061
|
with no pick, which the list's schema refuses on that row.
|
|
1062
|
+
- **Items that come from elsewhere** (a record's files, added by upload):
|
|
1063
|
+
`add` in place of `newItem` and `addLabel`. It renders the app's own
|
|
1064
|
+
control (an "Add files" button over a hidden file input) and is given
|
|
1065
|
+
`add(items)`, which saves them at the end through `save`, as a move is
|
|
1066
|
+
("Saving…", a refusal under the list, the caret back on the control),
|
|
1067
|
+
plus `items` (to stop at a limit), `disabled` (a change is saving) and
|
|
1068
|
+
`onProblem(message)` (an upload refused, under the list). Each item
|
|
1069
|
+
then reads, edits, moves and removes like a checklist's.
|
|
1059
1070
|
|
|
1060
1071
|
```tsx
|
|
1061
1072
|
const checklist = editInPlace<ChecklistItem>({
|
|
@@ -1076,6 +1087,26 @@ The row's fields are one component shared with the form
|
|
|
1076
1087
|
(`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
|
|
1077
1088
|
position ("Item 2 text") as the row buttons name the item.
|
|
1078
1089
|
|
|
1090
|
+
A list added by upload:
|
|
1091
|
+
|
|
1092
|
+
```tsx
|
|
1093
|
+
const files = editInPlace<Attachment>({
|
|
1094
|
+
kind: 'list',
|
|
1095
|
+
value: record.attachments,
|
|
1096
|
+
save: (change) => editField.mutateAsync((current) => ({ attachments: change(current.attachments) })),
|
|
1097
|
+
itemName: (index) => record.attachments[index]?.name ?? `file ${index + 1}`,
|
|
1098
|
+
add: ({ add, items, disabled, onProblem }) => (
|
|
1099
|
+
<UploadButton // the app's: a button over <input type="file" hidden>
|
|
1100
|
+
disabled={disabled || items.length >= MAX}
|
|
1101
|
+
onUploaded={(stored) => add(stored.map(toAttachment))}
|
|
1102
|
+
onRefused={(error) => onProblem(error.message)}
|
|
1103
|
+
/>
|
|
1104
|
+
),
|
|
1105
|
+
renderShown: (file) => <FileRow file={file} />, // its thumbnail, name and caption
|
|
1106
|
+
renderItem: (props) => <CaptionInput {...props} />, // what edits on a click: the caption
|
|
1107
|
+
})
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1079
1110
|
### In a table's rows
|
|
1080
1111
|
|
|
1081
1112
|
For a **small entity edited in its table** (a name and a few fields,
|
|
@@ -3,20 +3,21 @@
|
|
|
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
|
|
6
|
+
- [Semantic tones](#semantic-tones) · lines 30–58
|
|
7
|
+
- [The three badge styles](#the-three-badge-styles) · lines 60–77
|
|
8
|
+
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 79–108
|
|
9
|
+
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 110–123
|
|
10
|
+
- [Logos and brand marks](#logos-and-brand-marks) · lines 125–169
|
|
11
|
+
- [Categorical colour](#categorical-colour) · lines 171–241
|
|
12
|
+
- [Typography](#typography) · lines 243–325
|
|
13
|
+
- [Written text (notes)](#written-text-notes) · lines 274–325
|
|
14
|
+
- [Density](#density) · lines 327–384
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 386–464
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 466–524
|
|
17
|
+
- [Notices](#notices) · lines 526–554
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 556–589
|
|
19
|
+
- [A list as a board](#a-list-as-a-board) · lines 591–642
|
|
20
|
+
- [A list as cards](#a-list-as-cards) · lines 644–690
|
|
20
21
|
|
|
21
22
|
<!-- /index -->
|
|
22
23
|
|
|
@@ -639,3 +640,51 @@ the table's.
|
|
|
639
640
|
columns share the width, at least 16rem each, and scroll sideways with
|
|
640
641
|
snap points; a phone shows one column at a time, with the edge of the
|
|
641
642
|
next one showing.
|
|
643
|
+
|
|
644
|
+
## A list as cards
|
|
645
|
+
|
|
646
|
+
`DataTable`'s `layouts` (4.39) draw the same rows as cards beside the
|
|
647
|
+
table: a thumbnail grid for a feed, covers for a collection, a dense line
|
|
648
|
+
each for clearing a backlog. They are layouts of one list, not other
|
|
649
|
+
pages: the toolbar, filters, saved views, paging and `groupBy` headings
|
|
650
|
+
are the table's in every one, and so are its loading, empty and error
|
|
651
|
+
states.
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
const { layout, setLayout } = useTableLayout({
|
|
655
|
+
layouts: ['table', 'covers'],
|
|
656
|
+
defaultLayout: 'covers',
|
|
657
|
+
storageKey: 'my-app:books:layout',
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
<DataTable
|
|
661
|
+
{...tableProps}
|
|
662
|
+
layouts={[{ id: 'covers', label: 'Covers', icon: LayoutGridIcon, size: 'sm', aspect: '2:3', renderCard: (book) => <BookCard book={book} /> }]}
|
|
663
|
+
layout={layout}
|
|
664
|
+
onLayoutChange={setLayout}
|
|
665
|
+
/>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
- **The switch** sits at the end of the toolbar, the table first (rename
|
|
669
|
+
it with `tableLayout`, "Rows" for a list-like table with no header).
|
|
670
|
+
The pick is in the URL (`?layout=`) and remembered per table on the
|
|
671
|
+
device, so name the `storageKey` for the list. A list opens on the
|
|
672
|
+
layout it is read in most: covers for something browsed by its art, the
|
|
673
|
+
table for something compared by its columns.
|
|
674
|
+
- **A grid's sizes** are the package's: `sm` for covers (three across on
|
|
675
|
+
a phone), `md` for thumbnails (two), `lg` for big ones (one). `list`
|
|
676
|
+
is one card per line in a bordered box, divided like rows.
|
|
677
|
+
- **The picture** is `Artwork`: a fixed shape (2:3 posters and books,
|
|
678
|
+
1:1 square art, 16:9 video), the record's image or a neutral
|
|
679
|
+
placeholder (the list's icon, and the title where most records have no
|
|
680
|
+
picture), so the grid's rows stay even. Its corners hold an
|
|
681
|
+
`ArtworkTag` (a length, "Live") and an `ArtworkToggle`, a round button
|
|
682
|
+
that marks the record (Read, Owned): dark on the picture while off,
|
|
683
|
+
success's fill while on.
|
|
684
|
+
- **The card holds what the row does**: the title as the link to the
|
|
685
|
+
record (cell pattern 16), the list's key line in muted text, then its
|
|
686
|
+
status, rating or quality as the table's own cells draw them, and the
|
|
687
|
+
same actions. A card leaves an empty value out rather than showing a
|
|
688
|
+
dash. Never an action only one layout has.
|
|
689
|
+
- `totals` and `highlightRow` are the table's alone. Sorting is the
|
|
690
|
+
toolbar's in a card layout (there is no header to click).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { IconComponent } from './icon-component.js';
|
|
3
|
+
export type ArtworkAspect = '2:3' | '1:1' | '16:9';
|
|
4
|
+
export type ArtworkProps = {
|
|
5
|
+
/** The image's URL; null or undefined for the placeholder. */
|
|
6
|
+
src?: string | null;
|
|
7
|
+
alt?: string;
|
|
8
|
+
aspect: ArtworkAspect;
|
|
9
|
+
/** The placeholder's icon (the list's own: a book, a die); a picture icon when left out. */
|
|
10
|
+
placeholderIcon?: IconComponent;
|
|
11
|
+
/** Words under the placeholder's icon, often the title, for a grid where most records have no picture. */
|
|
12
|
+
placeholderLabel?: ReactNode;
|
|
13
|
+
topStart?: ReactNode;
|
|
14
|
+
topEnd?: ReactNode;
|
|
15
|
+
bottomStart?: ReactNode;
|
|
16
|
+
bottomEnd?: ReactNode;
|
|
17
|
+
/** Dims the picture (a watched video), never its overlays. */
|
|
18
|
+
dimmed?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function Artwork({ src, alt, aspect, placeholderIcon: PlaceholderIcon, placeholderLabel, topStart, topEnd, bottomStart, bottomEnd, dimmed, className, }: ArtworkProps): import("react").JSX.Element;
|
|
22
|
+
/**
|
|
23
|
+
* A small label on a picture (a length, "Live", "Short"): the page's
|
|
24
|
+
* foreground as its background, so it reads on any image in either theme.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ArtworkTag({ children, className }: {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
}): import("react").JSX.Element;
|
|
30
|
+
export type ArtworkToggleProps = {
|
|
31
|
+
/** What it marks, with the record ("Read: Saga"), for a screen reader and the tooltip. */
|
|
32
|
+
label: string;
|
|
33
|
+
pressed: boolean;
|
|
34
|
+
onPressedChange: (pressed: boolean) => void;
|
|
35
|
+
icon: IconComponent;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A round button on a picture that marks the record (Read, Owned): dark
|
|
40
|
+
* on the picture while off, success's solid fill while on, so it reads on
|
|
41
|
+
* any image. Goes in one of Artwork's corners.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ArtworkToggle({ label, pressed, onPressedChange, icon: Icon, disabled }: ArtworkToggleProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A card's picture (DataTable's card layouts): a box of a fixed shape, a
|
|
3
|
+
// poster's 2:3, a square cover's 1:1 or a video's 16:9, holding the
|
|
4
|
+
// record's image, or a neutral placeholder when it has none (or it fails
|
|
5
|
+
// to load), so a grid of cards keeps its rows even. Overlays sit on top:
|
|
6
|
+
// `topStart`/`topEnd`/`bottomStart`/`bottomEnd` corners for a badge, a
|
|
7
|
+
// length, or a round button such as Read or Owned.
|
|
8
|
+
//
|
|
9
|
+
// The image is decorative by default (`alt=""`): the card's title, beside
|
|
10
|
+
// it, already names the record. Give `alt` only when the picture says
|
|
11
|
+
// something the card doesn't.
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { ImageIcon } from 'lucide-react';
|
|
14
|
+
import { cn } from 'cn';
|
|
15
|
+
const ASPECT_CLASS = {
|
|
16
|
+
'2:3': 'aspect-[2/3]',
|
|
17
|
+
'1:1': 'aspect-square',
|
|
18
|
+
'16:9': 'aspect-video',
|
|
19
|
+
};
|
|
20
|
+
const CORNER_CLASS = 'absolute z-10 flex items-center gap-1';
|
|
21
|
+
export function Artwork({ src, alt = '', aspect, placeholderIcon: PlaceholderIcon = ImageIcon, placeholderLabel, topStart, topEnd, bottomStart, bottomEnd, dimmed, className, }) {
|
|
22
|
+
// The src that failed, so a new one gets its own try.
|
|
23
|
+
const [failed, setFailed] = useState(null);
|
|
24
|
+
const showImage = Boolean(src) && failed !== src;
|
|
25
|
+
return (_jsxs("div", { "data-slot": "artwork", "data-aspect": aspect, className: cn('relative w-full overflow-hidden rounded-md bg-muted', ASPECT_CLASS[aspect], className), children: [showImage ? (_jsx("img", { src: src ?? undefined, alt: alt, loading: "lazy", decoding: "async", onError: () => setFailed(src ?? null), className: cn('size-full object-cover', dimmed && 'opacity-55') })) : (_jsxs("div", { "data-slot": "artwork-placeholder", className: cn('flex size-full flex-col items-center justify-center gap-2 p-3 text-center text-muted-foreground', dimmed && 'opacity-55'), children: [_jsx(PlaceholderIcon, { "aria-hidden": "true", className: "size-6 shrink-0" }), placeholderLabel && (_jsx("span", { className: "type-caption line-clamp-3 text-balance text-foreground", children: placeholderLabel }))] })), topStart && _jsx("div", { className: cn(CORNER_CLASS, 'top-1.5 left-1.5'), children: topStart }), topEnd && _jsx("div", { className: cn(CORNER_CLASS, 'top-1.5 right-1.5'), children: topEnd }), bottomStart && _jsx("div", { className: cn(CORNER_CLASS, 'bottom-1.5 left-1.5'), children: bottomStart }), bottomEnd && _jsx("div", { className: cn(CORNER_CLASS, 'right-1.5 bottom-1.5'), children: bottomEnd })] }));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A small label on a picture (a length, "Live", "Short"): the page's
|
|
29
|
+
* foreground as its background, so it reads on any image in either theme.
|
|
30
|
+
*/
|
|
31
|
+
export function ArtworkTag({ children, className }) {
|
|
32
|
+
return (_jsx("span", { "data-slot": "artwork-tag", className: cn('rounded-sm bg-foreground/85 px-1 font-mono text-[0.6875rem] leading-4 font-medium text-background tabular-nums', className), children: children }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A round button on a picture that marks the record (Read, Owned): dark
|
|
36
|
+
* on the picture while off, success's solid fill while on, so it reads on
|
|
37
|
+
* any image. Goes in one of Artwork's corners.
|
|
38
|
+
*/
|
|
39
|
+
export function ArtworkToggle({ label, pressed, onPressedChange, icon: Icon, disabled }) {
|
|
40
|
+
return (_jsx("button", { type: "button", "aria-pressed": pressed, "aria-label": label, title: label, disabled: disabled, onClick: () => onPressedChange(!pressed), className: cn('grid size-8 place-items-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50', 'disabled:pointer-events-none disabled:opacity-50', pressed
|
|
41
|
+
? 'bg-success text-success-foreground hover:bg-success/90'
|
|
42
|
+
: 'bg-foreground/85 text-background hover:bg-foreground'), children: _jsx(Icon, { "aria-hidden": "true", className: "size-4" }) }));
|
|
43
|
+
}
|
|
@@ -2,6 +2,8 @@ import { type ReactNode } from 'react';
|
|
|
2
2
|
import type { CellData, RowData, TableFeatures } from '@tanstack/react-table';
|
|
3
3
|
import { type LegacyColumnDef } from '@tanstack/react-table/legacy';
|
|
4
4
|
import type { AppError } from '../../api/contracts.js';
|
|
5
|
+
import type { ArtworkAspect } from './artwork.js';
|
|
6
|
+
import type { IconComponent } from './icon-component.js';
|
|
5
7
|
import { type RowGroup } from './table-groups.js';
|
|
6
8
|
export type SortingState = {
|
|
7
9
|
id: string;
|
|
@@ -107,6 +109,52 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
|
|
|
107
109
|
* has come) for no row.
|
|
108
110
|
*/
|
|
109
111
|
totals?: DataTableTotals | null;
|
|
112
|
+
/**
|
|
113
|
+
* Card layouts besides the table (a thumbnail grid, covers, one line
|
|
114
|
+
* each), and a switch between them at the end of the toolbar, the table
|
|
115
|
+
* first. Each draws a row with its own `renderCard`; paging, `groupBy`
|
|
116
|
+
* headings and the loading, empty and error states hold in every one.
|
|
117
|
+
* The columns still define the table, and sorting is the toolbar's in a
|
|
118
|
+
* card layout. `totals` and `highlightRow` are the table's alone.
|
|
119
|
+
*/
|
|
120
|
+
layouts?: readonly DataTableCardLayout<TData>[];
|
|
121
|
+
/**
|
|
122
|
+
* The layout showing: `'table'` or a card layout's id, from
|
|
123
|
+
* useTableLayout (the URL's `?layout=`, then the one remembered for this
|
|
124
|
+
* table, then its default). An id the table doesn't have shows the table.
|
|
125
|
+
* Left out, the switch keeps its own state, starting on the table.
|
|
126
|
+
*/
|
|
127
|
+
layout?: string;
|
|
128
|
+
onLayoutChange?: (layout: string) => void;
|
|
129
|
+
/** The table's own entry in the switch: "Table" with a table icon unless set ("Rows", for a list-like table). */
|
|
130
|
+
tableLayout?: {
|
|
131
|
+
label?: string;
|
|
132
|
+
icon?: IconComponent;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/** One of DataTable's card layouts (`layouts`). */
|
|
136
|
+
export type DataTableCardLayout<TData> = {
|
|
137
|
+
/** In the URL (`?layout=covers`) and remembered, so keep it short and stable. */
|
|
138
|
+
id: string;
|
|
139
|
+
/** The switch's word for it ("Covers"); its accessible name on a phone, where only the icon shows. */
|
|
140
|
+
label: string;
|
|
141
|
+
icon: IconComponent;
|
|
142
|
+
/** A row as a card. Everything it holds is the app's: the picture (Artwork), the title link, the actions. */
|
|
143
|
+
renderCard: (row: TData) => ReactNode;
|
|
144
|
+
/**
|
|
145
|
+
* `grid` (the default): cards side by side, as many across as fit, the
|
|
146
|
+
* package's sizing from the table's width; `list`: one card per line,
|
|
147
|
+
* divided like rows, for a dense one-line-each layout.
|
|
148
|
+
*/
|
|
149
|
+
arrange?: 'grid' | 'list';
|
|
150
|
+
/**
|
|
151
|
+
* A grid card's width: `sm` covers (about 8.5rem, three across on a
|
|
152
|
+
* phone), `md` (the default) thumbnails (11rem, two on a phone), `lg`
|
|
153
|
+
* big thumbnails (14rem, one on a phone).
|
|
154
|
+
*/
|
|
155
|
+
size?: 'sm' | 'md' | 'lg';
|
|
156
|
+
/** The picture's shape, for the loading skeleton's cards: 16:9 unless set. */
|
|
157
|
+
aspect?: ArtworkAspect;
|
|
110
158
|
};
|
|
111
159
|
export type DataTableWidth = 'full' | 'content';
|
|
112
160
|
export type DataTableTotals = {
|
|
@@ -119,4 +167,4 @@ export type DataTableTotals = {
|
|
|
119
167
|
*/
|
|
120
168
|
cells?: Partial<Record<string, ReactNode>>;
|
|
121
169
|
};
|
|
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;
|
|
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;
|
|
@@ -14,11 +14,12 @@ 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 } from 'lucide-react';
|
|
17
|
+
import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, TableIcon } from 'lucide-react';
|
|
18
18
|
import { ErrorState } from './error-state.js';
|
|
19
19
|
import { Button } from '../ui/button.js';
|
|
20
20
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
22
23
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
23
24
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
24
25
|
import { LeaveGuard } from './editable-value.js';
|
|
@@ -38,6 +39,41 @@ function leafColumnIds(columns) {
|
|
|
38
39
|
return id === undefined ? [] : [id];
|
|
39
40
|
});
|
|
40
41
|
}
|
|
42
|
+
// A card grid's columns: as many of the size as the table's width holds,
|
|
43
|
+
// and a fixed count on a phone-width table (`@container/data-table-cards`,
|
|
44
|
+
// under 28rem), where minimum widths would leave one lonely column.
|
|
45
|
+
const CARD_GRID_CLASS = {
|
|
46
|
+
sm: 'grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] @max-md/data-table-cards:grid-cols-3',
|
|
47
|
+
md: 'grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] @max-md/data-table-cards:grid-cols-2',
|
|
48
|
+
lg: 'grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] @max-md/data-table-cards:grid-cols-1',
|
|
49
|
+
};
|
|
50
|
+
const CARD_GRID_GAP_CLASS = 'gap-x-4 gap-y-6 @max-md/data-table-cards:gap-x-2.5 @max-md/data-table-cards:gap-y-4';
|
|
51
|
+
// A `list` layout: rows in a bordered box, divided as the table's are.
|
|
52
|
+
const CARD_LIST_CLASS = 'divide-y divide-border overflow-hidden rounded-lg border border-border';
|
|
53
|
+
const SKELETON_ASPECT_CLASS = {
|
|
54
|
+
'2:3': 'aspect-[2/3]',
|
|
55
|
+
'1:1': 'aspect-square',
|
|
56
|
+
'16:9': 'aspect-video',
|
|
57
|
+
};
|
|
58
|
+
function CardsSkeleton({ layout }) {
|
|
59
|
+
if (layout.arrange === 'list') {
|
|
60
|
+
return (_jsx("div", { className: cn(CARD_LIST_CLASS, 'flex flex-col'), "data-state": "loading", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [_jsx(Skeleton, { className: "size-6 shrink-0 rounded-full" }), _jsx(Skeleton, { className: "h-4 flex-1" })] }, index))) }));
|
|
61
|
+
}
|
|
62
|
+
return (_jsx("div", { className: "@container/data-table-cards", "data-state": "loading", children: _jsx("div", { className: cn('grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS), children: Array.from({ length: 8 }, (_, index) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: cn('w-full rounded-md', SKELETON_ASPECT_CLASS[layout.aspect ?? '16:9']) }), _jsx(Skeleton, { className: "h-4 w-4/5" }), _jsx(Skeleton, { className: "h-3 w-1/2" })] }, index))) }) }));
|
|
63
|
+
}
|
|
64
|
+
// The layout switch: a toggle group (one tab stop, the arrow keys along
|
|
65
|
+
// it), the pressed one in the muted fill with the foreground's colour and
|
|
66
|
+
// medium weight, the others in muted text, so the difference isn't the
|
|
67
|
+
// fill alone. On a phone only the icons show; the label stays their name.
|
|
68
|
+
const LAYOUT_ITEM_CLASS = 'gap-1.5 text-muted-foreground aria-pressed:bg-muted aria-pressed:font-medium aria-pressed:text-foreground';
|
|
69
|
+
function LayoutSwitch({ options, value, onValueChange, }) {
|
|
70
|
+
return (_jsx(ToggleGroup, { "aria-label": "Layout", variant: "outline", size: "sm", spacing: 0, "data-slot": "data-table-layout-switch", value: [value], onValueChange: (next) => {
|
|
71
|
+
// [] is the pressed one pressed again: it stays on.
|
|
72
|
+
const chosen = next[0];
|
|
73
|
+
if (chosen !== undefined && chosen !== value)
|
|
74
|
+
onValueChange(chosen);
|
|
75
|
+
}, 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
|
+
}
|
|
41
77
|
function TableSkeleton({ columnCount }) {
|
|
42
78
|
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))) }));
|
|
43
79
|
}
|
|
@@ -172,7 +208,17 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
172
208
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
173
209
|
return { barRef, isOverflowing, scrollWidth };
|
|
174
210
|
}
|
|
175
|
-
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, }) {
|
|
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, }) {
|
|
212
|
+
// The switch's own state, for a table that doesn't pass `layout`.
|
|
213
|
+
const [ownLayout, setOwnLayout] = useState('table');
|
|
214
|
+
const requestedLayout = layoutProp ?? ownLayout;
|
|
215
|
+
const cardLayout = layouts?.find((candidate) => candidate.id === requestedLayout);
|
|
216
|
+
const currentLayout = cardLayout?.id ?? 'table';
|
|
217
|
+
const setLayout = (next) => {
|
|
218
|
+
if (layoutProp === undefined)
|
|
219
|
+
setOwnLayout(next);
|
|
220
|
+
onLayoutChange?.(next);
|
|
221
|
+
};
|
|
176
222
|
// Editing in place in the rows (cell pattern 18, "In a table's rows"):
|
|
177
223
|
// a cell that renders an <EditableValue> edits where it's shown, one at
|
|
178
224
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
@@ -229,15 +275,18 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
229
275
|
return () => observer.disconnect();
|
|
230
276
|
}, [width, isSuccessView]);
|
|
231
277
|
const containerRef = useRef(null);
|
|
232
|
-
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
|
|
278
|
+
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView && !cardLayout);
|
|
233
279
|
const rows = table.getRowModel().rows;
|
|
234
280
|
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
235
281
|
const visibleColumnCount = visibleLeafColumns.length;
|
|
236
282
|
// One body row, the same in a plain table and a grouped one.
|
|
237
283
|
const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
|
|
238
|
-
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
|
|
284
|
+
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
|
|
239
285
|
? { '--data-table-last-width': `${lastWidth}px` }
|
|
240
|
-
: undefined, children: [
|
|
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: [
|
|
287
|
+
{ id: 'table', label: tableLayout?.label ?? 'Table', icon: tableLayout?.icon ?? TableIcon },
|
|
288
|
+
...layouts,
|
|
289
|
+
], value: currentLayout, onValueChange: setLayout })] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, rows: rows, groupBy: groupBy, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef,
|
|
241
290
|
// With the sticky bar below as the visible scrollbar, hide the
|
|
242
291
|
// container's own. It stays overflow-x-auto and fully
|
|
243
292
|
// scrollable (wheel, trackpad, drag, and the sync effect); it
|
|
@@ -269,6 +318,15 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
269
318
|
}) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
|
|
270
319
|
const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
|
|
271
320
|
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));
|
|
272
|
-
}) }) }))] }), 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, {}) })] })] })] }))] }));
|
|
321
|
+
}) }) }))] })), 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, {}) })] })] })] }))] }));
|
|
273
322
|
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
|
|
274
323
|
}
|
|
324
|
+
// A card layout's success view: the rows as cards in a grid or a list,
|
|
325
|
+
// each run of one group (`groupBy`) under its own heading, as the table's
|
|
326
|
+
// heading rows are, with the same label and summary.
|
|
327
|
+
function Cards({ layout, rows, groupBy, groupSummary, }) {
|
|
328
|
+
const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0", children: layout.renderCard(row.original) }, row.id))) }));
|
|
329
|
+
return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: groupBy
|
|
330
|
+
? groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs("section", { "data-slot": "table-group", className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }), list(run.rows)] }, `${runIndex}:${run.key}`)))
|
|
331
|
+
: list(rows) }));
|
|
332
|
+
}
|
|
@@ -123,14 +123,31 @@ export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
|
123
123
|
/** The item being added at the end, not one already in the list. */
|
|
124
124
|
isNew: boolean;
|
|
125
125
|
};
|
|
126
|
+
/** What a list's own `add` is given: how to put new items at the end, saved as the list's other changes are. */
|
|
127
|
+
export type EditListAddProps<TItem> = {
|
|
128
|
+
/**
|
|
129
|
+
* Saves these items at the end of the list, through the list's `save`
|
|
130
|
+
* (built on its latest list, after whatever is open and whatever is
|
|
131
|
+
* still saving). "Saving…" and a refusal show under the list, as for a
|
|
132
|
+
* move. Resolves once it's done: `true` saved, `false` refused.
|
|
133
|
+
*/
|
|
134
|
+
add: (items: TItem[]) => Promise<boolean>;
|
|
135
|
+
/** The list as saved: to stop adding at a limit (`items.length >= max`). */
|
|
136
|
+
items: readonly TItem[];
|
|
137
|
+
/** A change is saving: the list's buttons wait, and so should this one. */
|
|
138
|
+
disabled: boolean;
|
|
139
|
+
/** Why the add can't happen (an upload refused), under the list where a refused save shows; `null` clears it. */
|
|
140
|
+
onProblem: (message: string | null) => void;
|
|
141
|
+
};
|
|
126
142
|
/**
|
|
127
|
-
* A list of sub-items edited in place (a checklist, a list of links
|
|
128
|
-
* a `content` section of EntityView. Make one with
|
|
129
|
-
* 'list', ... })`. Every item reads as `renderShown`
|
|
130
|
-
* as its done box, still works there); clicking it
|
|
131
|
-
* into `renderItem`, the form's own row. "Add" at
|
|
132
|
-
*
|
|
133
|
-
*
|
|
143
|
+
* A list of sub-items edited in place (a checklist, a list of links, a
|
|
144
|
+
* record's files), on a `content` section of EntityView. Make one with
|
|
145
|
+
* `editInPlace({ kind: 'list', ... })`. Every item reads as `renderShown`
|
|
146
|
+
* (a quick action, such as its done box, still works there); clicking it
|
|
147
|
+
* or its pencil turns it into `renderItem`, the form's own row. "Add" at
|
|
148
|
+
* the end opens a new item (`newItem`), or the app's own `add` puts items
|
|
149
|
+
* there (an upload); move up, move down and remove are ListEditor's
|
|
150
|
+
* buttons. Each change is one save of the whole list.
|
|
134
151
|
*/
|
|
135
152
|
export type EditInPlaceListOptions<TItem> = {
|
|
136
153
|
kind: 'list';
|
|
@@ -145,12 +162,19 @@ export type EditInPlaceListOptions<TItem> = {
|
|
|
145
162
|
renderItem: (props: EditListItemProps<TItem>) => ReactNode;
|
|
146
163
|
/** How the item reads when it isn't being edited. Its own controls (a done box) keep working. */
|
|
147
164
|
renderShown: (item: TItem, index: number) => ReactNode;
|
|
148
|
-
/** A blank item, what "Add" starts from. */
|
|
149
|
-
newItem
|
|
165
|
+
/** A blank item, what "Add" starts from. Not needed with `add`; one or the other is. */
|
|
166
|
+
newItem?: () => TItem;
|
|
167
|
+
/**
|
|
168
|
+
* In place of "Add" and its blank item: what adds items, such as a file
|
|
169
|
+
* upload or a picker, e.g. a button that uploads, then calls
|
|
170
|
+
* `props.add(stored.map(toItem))`. The items save at the end of the list
|
|
171
|
+
* through `save`, so they read, move and save like any other.
|
|
172
|
+
*/
|
|
173
|
+
add?: (props: EditListAddProps<TItem>) => ReactNode;
|
|
150
174
|
/** How the buttons name an item, as on the form: (i) => `item ${i + 1}` gives "Move item 2 up". */
|
|
151
175
|
itemName: (index: number) => string;
|
|
152
|
-
/** The Add button's text, e.g. "Add item". */
|
|
153
|
-
addLabel
|
|
176
|
+
/** The Add button's text, e.g. "Add item". Not needed with `add`, whose control has its own. */
|
|
177
|
+
addLabel?: string;
|
|
154
178
|
/**
|
|
155
179
|
* The form's schema for the whole list (`widgetFormSchema.shape.checklist`).
|
|
156
180
|
* Each change is checked as the list it would make; a message about an
|
|
@@ -169,7 +193,8 @@ export type EditInPlaceListOptions<TItem> = {
|
|
|
169
193
|
save: (change: (current: TItem[]) => TItem[]) => Promise<unknown>;
|
|
170
194
|
};
|
|
171
195
|
/** A list marked editable in place. Make one with `editInPlace({ kind: 'list', ... })`. */
|
|
172
|
-
export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save'> & {
|
|
196
|
+
export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save' | 'add'> & {
|
|
197
|
+
add?: (props: EditListAddProps<unknown>) => ReactNode;
|
|
173
198
|
renderItem: (props: EditListItemProps<unknown>) => ReactNode;
|
|
174
199
|
renderShown: (item: unknown, index: number) => ReactNode;
|
|
175
200
|
schema?: {
|
|
@@ -9,7 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// form's row (`renderItem`), an EditableValue like any field: leaving or
|
|
10
10
|
// Enter saves, Esc gives up, one field open at a time.
|
|
11
11
|
// - "Add" at the end opens a new item the same way. Enter saves it and
|
|
12
|
-
// opens the next, so a run of items goes in from the keyboard.
|
|
12
|
+
// opens the next, so a run of items goes in from the keyboard. Or the
|
|
13
|
+
// app's own `add` (an upload) puts items at the end, saved as a move is.
|
|
13
14
|
// - Move and remove save at once. Whatever is open is saved first; while
|
|
14
15
|
// the change saves, the buttons wait, "Saving…" shows under the list,
|
|
15
16
|
// and a refusal leaves the list as it was with the reason under it.
|
|
@@ -56,16 +57,22 @@ function checkItem(edit, items, index, draft) {
|
|
|
56
57
|
const issue = issues.find(({ path }) => path?.[0] === index) ?? issues.find(({ path }) => !path?.length) ?? issues[0];
|
|
57
58
|
return { success: false, error: { issues: issue ? [issue] : [] } };
|
|
58
59
|
}
|
|
60
|
+
// The app's own add, as a component of its own: it renders the app's
|
|
61
|
+
// control with what it's given, and holds nothing.
|
|
62
|
+
function ListAdd({ render, ...props }) {
|
|
63
|
+
return _jsx("div", { "data-list-add-slot": "", children: render(props) });
|
|
64
|
+
}
|
|
59
65
|
export function EditableList({ label, edit, emptyLabel }) {
|
|
60
66
|
const store = useContext(EditInPlaceContext);
|
|
61
67
|
if (!store)
|
|
62
68
|
throw new Error('A list edited in place works inside an <EntityView>, which runs editing in place.');
|
|
63
69
|
const items = edit.value;
|
|
64
70
|
const statusId = useId();
|
|
65
|
-
const [status, setStatus] = useState({ saving: false, error: null });
|
|
71
|
+
const [status, setStatus] = useState({ saving: false, inFlight: 0, error: null });
|
|
66
72
|
// The buttons are disabled while a change saves, which drops the caret.
|
|
67
73
|
// Once the list shows the outcome it goes to the first of these buttons
|
|
68
|
-
// (by name) that's there and enabled, else to Add
|
|
74
|
+
// (by name) that's there and enabled, else to Add (or the app's add's
|
|
75
|
+
// first button).
|
|
69
76
|
const root = useRef(null);
|
|
70
77
|
const focusNext = useRef(null);
|
|
71
78
|
useEffect(() => {
|
|
@@ -75,26 +82,30 @@ export function EditableList({ label, edit, emptyLabel }) {
|
|
|
75
82
|
focusNext.current = null;
|
|
76
83
|
const buttons = [...(root.current?.querySelectorAll('button:not(:disabled)') ?? [])];
|
|
77
84
|
const target = names.map((name) => buttons.find((button) => button.getAttribute('aria-label') === name)).find(Boolean);
|
|
78
|
-
(target ?? root.current?.querySelector('[data-list-add]:not(:disabled)'))?.focus();
|
|
85
|
+
(target ?? root.current?.querySelector('[data-list-add]:not(:disabled), [data-list-add-slot] button:not(:disabled)'))?.focus();
|
|
79
86
|
}, [items, status.saving]);
|
|
80
|
-
// Move and
|
|
81
|
-
// and nothing else happens), then the change.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
// Move, remove and the app's own add: save the open field first (if it
|
|
88
|
+
// can't save, it stays and nothing else happens), then the change. An
|
|
89
|
+
// add that lands while a change saves (an upload finishing) still goes:
|
|
90
|
+
// `save` builds on the latest list, and "Saving…" stays until both are done.
|
|
91
|
+
const act = async (change, focusAfter, focusIfRefused, waits = true) => {
|
|
92
|
+
if (waits && status.saving)
|
|
93
|
+
return false;
|
|
85
94
|
const open = store.open;
|
|
86
95
|
if (open !== null && !(await store.commit(open)))
|
|
87
|
-
return;
|
|
88
|
-
setStatus({ saving: true, error: null });
|
|
96
|
+
return false;
|
|
97
|
+
setStatus((now) => ({ saving: true, inFlight: now.inFlight + 1, error: null }));
|
|
98
|
+
let error = null;
|
|
89
99
|
try {
|
|
90
100
|
await edit.save(change);
|
|
91
101
|
focusNext.current = focusAfter;
|
|
92
|
-
setStatus({ saving: false, error: null });
|
|
93
102
|
}
|
|
94
|
-
catch (
|
|
103
|
+
catch (refused) {
|
|
95
104
|
focusNext.current = focusIfRefused;
|
|
96
|
-
|
|
105
|
+
error = messageOf(refused);
|
|
97
106
|
}
|
|
107
|
+
setStatus((now) => ({ saving: now.inFlight > 1, inFlight: now.inFlight - 1, error: error ?? now.error }));
|
|
108
|
+
return error === null;
|
|
98
109
|
};
|
|
99
110
|
const move = (from, to) => {
|
|
100
111
|
const direction = to < from ? 'up' : 'down';
|
|
@@ -124,13 +135,19 @@ export function EditableList({ label, edit, emptyLabel }) {
|
|
|
124
135
|
return current.map((other, i) => (i === at ? item : other));
|
|
125
136
|
}),
|
|
126
137
|
});
|
|
138
|
+
// The app's own add: its items saved at the end, the caret back on it.
|
|
139
|
+
const addItems = (added) => added.length === 0 ? Promise.resolve(true) : act((current) => [...current, ...added], [], [], false);
|
|
140
|
+
const onProblem = (message) => setStatus((now) => ({ ...now, error: message }));
|
|
127
141
|
// The item being added, at the end.
|
|
142
|
+
const newItem = edit.newItem;
|
|
143
|
+
if (!newItem && !edit.add)
|
|
144
|
+
throw new Error('A list edited in place needs `newItem` (a blank item to add) or `add` (its own control).');
|
|
128
145
|
const addEdit = {
|
|
129
146
|
kind: 'text',
|
|
130
|
-
value:
|
|
147
|
+
value: newItem?.(),
|
|
131
148
|
control: (props) => _jsx(ItemRow, { children: edit.renderItem({ ...props, index: items.length, isNew: true }) }),
|
|
132
149
|
schema: { safeParse: (draft) => checkItem(edit, items, items.length, draft) },
|
|
133
150
|
save: (item) => edit.save((current) => [...current, item]),
|
|
134
151
|
};
|
|
135
|
-
return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel, emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: _jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) }) }), _jsx("span", { id: statusId, className: "type-body", children: status.saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
|
|
152
|
+
return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel ?? '', emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: edit.add ? (_jsx(ListAdd, { render: edit.add, add: addItems, items: items, disabled: status.saving, onProblem: onProblem })) : (_jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) })) }), _jsx("span", { id: statusId, className: "type-body", children: status.saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
|
|
136
153
|
}
|
|
@@ -206,7 +206,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
206
206
|
if (state.status === 'closed' && !always) {
|
|
207
207
|
if (trigger)
|
|
208
208
|
return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
|
|
209
|
-
return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
|
|
209
|
+
return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1 group-hover/editable:[&_.text-muted-foreground]:text-foreground group-has-[[data-edit]:focus-visible]/editable:[&_.text-muted-foreground]:text-foreground", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
|
|
210
210
|
}
|
|
211
211
|
// Always editable, before the store has it (the first render): the saved value.
|
|
212
212
|
const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
|
2
|
+
export type LayoutStore = {
|
|
3
|
+
get: (key: string) => string | null;
|
|
4
|
+
set: (key: string, value: string) => void;
|
|
5
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function createLayoutStore(getStorage: () => StorageLike | undefined): LayoutStore;
|
|
8
|
+
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
9
|
+
export declare function resolveLayout(layouts: readonly string[], fromUrl: string | null, remembered: string | null, defaultLayout: string): string;
|
|
10
|
+
export type TableLayoutOptions = {
|
|
11
|
+
/** Every layout id the table has, the table's own `'table'` among them. */
|
|
12
|
+
layouts: readonly string[];
|
|
13
|
+
/** The one shown until another is picked; `'table'` when left out. */
|
|
14
|
+
defaultLayout?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Where the pick is remembered on this device, one key per table: name it
|
|
17
|
+
* for the app and the list (`"my-app:comics:layout"`). Without one the
|
|
18
|
+
* pick lives only in the URL.
|
|
19
|
+
*/
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
24
|
+
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
25
|
+
* does) and remembers it under `storageKey`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useTableLayout({ layouts, defaultLayout, storageKey }: TableLayoutOptions): {
|
|
28
|
+
layout: string;
|
|
29
|
+
setLayout: (next: string) => void;
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router';
|
|
3
|
+
export function createLayoutStore(getStorage) {
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const listeners = new Map();
|
|
6
|
+
return {
|
|
7
|
+
get(key) {
|
|
8
|
+
const kept = memory.get(key);
|
|
9
|
+
if (kept !== undefined)
|
|
10
|
+
return kept;
|
|
11
|
+
try {
|
|
12
|
+
return getStorage()?.getItem(key) ?? null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
set(key, value) {
|
|
19
|
+
memory.set(key, value);
|
|
20
|
+
try {
|
|
21
|
+
getStorage()?.setItem(key, value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
25
|
+
}
|
|
26
|
+
for (const listener of listeners.get(key) ?? [])
|
|
27
|
+
listener();
|
|
28
|
+
},
|
|
29
|
+
subscribe(key, listener) {
|
|
30
|
+
let set = listeners.get(key);
|
|
31
|
+
if (!set)
|
|
32
|
+
listeners.set(key, (set = new Set()));
|
|
33
|
+
set.add(listener);
|
|
34
|
+
return () => set.delete(listener);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
39
|
+
export function resolveLayout(layouts, fromUrl, remembered, defaultLayout) {
|
|
40
|
+
if (fromUrl !== null && layouts.includes(fromUrl))
|
|
41
|
+
return fromUrl;
|
|
42
|
+
if (remembered !== null && layouts.includes(remembered))
|
|
43
|
+
return remembered;
|
|
44
|
+
return defaultLayout;
|
|
45
|
+
}
|
|
46
|
+
const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
47
|
+
/**
|
|
48
|
+
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
49
|
+
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
50
|
+
* does) and remembers it under `storageKey`.
|
|
51
|
+
*/
|
|
52
|
+
export function useTableLayout({ layouts, defaultLayout = 'table', storageKey }) {
|
|
53
|
+
const [params, setParams] = useSearchParams();
|
|
54
|
+
const subscribe = useCallback((listener) => (storageKey === undefined ? () => { } : browserStore.subscribe(storageKey, listener)), [storageKey]);
|
|
55
|
+
const remembered = useSyncExternalStore(subscribe, () => (storageKey === undefined ? null : browserStore.get(storageKey)), () => null);
|
|
56
|
+
const layout = resolveLayout(layouts, params.get('layout'), remembered, defaultLayout);
|
|
57
|
+
const setLayout = useCallback((next) => {
|
|
58
|
+
if (storageKey !== undefined)
|
|
59
|
+
browserStore.set(storageKey, next);
|
|
60
|
+
setParams((previous) => {
|
|
61
|
+
const updated = new URLSearchParams(previous);
|
|
62
|
+
updated.set('layout', next);
|
|
63
|
+
return updated;
|
|
64
|
+
}, { replace: true });
|
|
65
|
+
}, [setParams, storageKey]);
|
|
66
|
+
return { layout, setLayout };
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type AppShellVersion, type BottomBarItem, type NavEntry, type NavItem, type NavSection, } from './components/app/app-shell.js';
|
|
2
2
|
export type { IconComponent } from './components/app/icon-component.js';
|
|
3
3
|
export { Board, type BoardColumn, type BoardMoveTarget, type BoardProps } from './components/app/board.js';
|
|
4
|
+
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
4
5
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
5
|
-
export { DataTable, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
6
|
+
export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
6
7
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
7
8
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
8
9
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
9
10
|
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
10
|
-
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
11
|
+
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';
|
|
11
12
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
12
13
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
13
14
|
export { ErrorState } from './components/app/error-state.js';
|
|
@@ -51,6 +52,7 @@ export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
|
51
52
|
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
52
53
|
export { createQueryClient } from './api/query-client.js';
|
|
53
54
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
55
|
+
export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
|
|
54
56
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
55
57
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
56
58
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell, } from './components/app/app-shell.js';
|
|
7
7
|
export { Board } from './components/app/board.js';
|
|
8
|
+
export { Artwork, ArtworkTag, ArtworkToggle, } from './components/app/artwork.js';
|
|
8
9
|
export { CountLink } from './components/app/count-link.js';
|
|
9
10
|
export { DataTable, } from './components/app/data-table.js';
|
|
10
11
|
export {} from './components/app/table-groups.js';
|
|
@@ -54,6 +55,7 @@ export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
|
54
55
|
export { createQueryClient } from './api/query-client.js';
|
|
55
56
|
// Hooks
|
|
56
57
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
58
|
+
export { useTableLayout } from './hooks/use-table-layout.js';
|
|
57
59
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
58
60
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
59
61
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.41.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": {
|