@tristan2828/ui-foundation 3.24.0 → 3.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/docs/add-an-entity.md +19 -6
- package/conventions/docs/cell-patterns.md +55 -5
- package/conventions/docs/design-language.md +30 -12
- package/conventions/docs/entity-plan-template.md +8 -7
- package/dist/components/app/data-table.js +13 -10
- package/dist/components/app/edit-in-place-store.d.ts +2 -0
- package/dist/components/app/edit-in-place-store.js +2 -1
- package/dist/components/app/edit-in-place.d.ts +62 -0
- package/dist/components/app/edit-in-place.js +2 -2
- package/dist/components/app/editable-list.d.ts +6 -0
- package/dist/components/app/editable-list.js +129 -0
- package/dist/components/app/editable-value.d.ts +13 -1
- package/dist/components/app/editable-value.js +30 -10
- package/dist/components/app/entity-view.d.ts +9 -3
- package/dist/components/app/entity-view.js +5 -2
- package/dist/components/app/list-editor.d.ts +7 -2
- package/dist/components/app/list-editor.js +3 -3
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/styles/index.css +4 -0
- package/styles/theme.css +31 -4
|
@@ -325,9 +325,16 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
325
325
|
ids as a set). A field gets `edit`, a `content` section gets `edit`,
|
|
326
326
|
the title gets `titleEdit={{ label, ...edit }}`, and a header badge
|
|
327
327
|
wraps itself in `<EditableValue label edit layout="inline">`. The
|
|
328
|
-
shown value stays exactly as before.
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
shown value stays exactly as before. A sub-record list the line
|
|
329
|
+
names is `editInPlace({ kind: 'list', ... })` on its `content`
|
|
330
|
+
section (cell pattern 18, "A list of sub-items"; copy `checklist` in
|
|
331
|
+
`widgetEdits`): the form's row as `renderItem`, from one component
|
|
332
|
+
shared with the form (`WidgetChecklistItemFields`), the item as the
|
|
333
|
+
view shows it (its quick action, if any) as `renderShown`, the list's
|
|
334
|
+
schema, and `save: (change) => edit.mutateAsync((current) => ({
|
|
335
|
+
<field>: change(current.<field>) }))`. **With no `Edit in place`
|
|
336
|
+
line, nothing is editable in place** and none of this is copied.
|
|
337
|
+
Never a computed field.
|
|
331
338
|
|
|
332
339
|
**The table opens the view, and the form returns to it.** The title
|
|
333
340
|
column's link is the way in (a `yes/no` marked `toggle` stays in its
|
|
@@ -433,7 +440,12 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
433
440
|
sent; a 422 (an MSW override, the reason on the field) and a 500 stay
|
|
434
441
|
open with the draft and the reason; opening a second field saves the
|
|
435
442
|
first, or doesn't open if that fails; and leaving the page with
|
|
436
|
-
unsaved text asks first.
|
|
443
|
+
unsaved text asks first. A list edited in place gets its own block
|
|
444
|
+
(copy `the checklist edited in place`): three items added by keyboard
|
|
445
|
+
alone and still there after leaving and coming back, an item edited
|
|
446
|
+
(Enter) and given up (Esc), move and remove with the PATCH each sends,
|
|
447
|
+
a blank item never sent, a refused change, and a tick plus an add both
|
|
448
|
+
landing. Add it to `mobile-chrome`'s `testMatch`. If
|
|
437
449
|
the plan has Markdown long text, a test forces a record whose text has
|
|
438
450
|
a heading, a list, a table, a link and raw HTML, and asserts the HTML
|
|
439
451
|
shows as text. Add the view's spec to `playwright.config.ts`'s
|
|
@@ -460,8 +472,9 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
460
472
|
page is picked up from the sidebar automatically; the form and the view
|
|
461
473
|
aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
|
|
462
474
|
"editing in place" block for them: a field open, saving and refused,
|
|
463
|
-
the rich-text editor, an open list,
|
|
464
|
-
|
|
475
|
+
the rich-text editor, an open list, a list edited in place (an item
|
|
476
|
+
open, one being added, a refused change), and the leave prompt, in
|
|
477
|
+
both themes.
|
|
465
478
|
10. **`npm run verify`.** Fix until it passes. Then stop — do not add
|
|
466
479
|
anything beyond what this list covers; note ideas in the app's
|
|
467
480
|
backlog instead.
|
|
@@ -663,11 +663,11 @@ The rule behind every part of it: **nothing typed is ever lost.**
|
|
|
663
663
|
|
|
664
664
|
**Which values.** Only those the plan's "View screen" names under `Edit
|
|
665
665
|
in place`; read-only is the default, and a value not named renders
|
|
666
|
-
exactly as it did.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
sub-
|
|
670
|
-
|
|
666
|
+
exactly as it did. Text, long text, a single choice (optional ones with
|
|
667
|
+
their "not set" option), an integer or a rating, a yes/no, a single or
|
|
668
|
+
multi reference, and a sub-record list (its own rules, under "A list of
|
|
669
|
+
sub-items" below). Never a computed field. The view's Edit button and
|
|
670
|
+
the form stay.
|
|
671
671
|
|
|
672
672
|
**At rest: says it's editable without shouting.** The value reads as it
|
|
673
673
|
always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
|
|
@@ -769,6 +769,56 @@ const price = editInPlace({
|
|
|
769
769
|
Keep it to the fields the plan names, and leave the form for everything
|
|
770
770
|
else.
|
|
771
771
|
|
|
772
|
+
### A list of sub-items
|
|
773
|
+
|
|
774
|
+
A sub-record list (a checklist, a list of links) on a `content` section,
|
|
775
|
+
edited where it's shown: `editInPlace({ kind: 'list', ... })`. Widget's
|
|
776
|
+
Checklist is the reference (`checklist` in `widgetEdits`). It's built on
|
|
777
|
+
`ListEditor`, so the rows and their buttons are the form's.
|
|
778
|
+
|
|
779
|
+
- **Each item reads as it always did** (`renderShown`), its own controls
|
|
780
|
+
included: a done box still ticks it on its own, a quick action. A click
|
|
781
|
+
on the item (not its box, not a link in it) or its pencil ("Edit item
|
|
782
|
+
2") turns it into the form's row (`renderItem`, the same fields as the
|
|
783
|
+
form's `ListEditor` row), an editable value like any above: Enter or
|
|
784
|
+
leaving saves, Esc gives up, one open at a time.
|
|
785
|
+
- **Add at the end.** "Add item" opens a new row; Enter saves it and
|
|
786
|
+
opens the next, the caret already in it, so a run of items goes in from
|
|
787
|
+
the keyboard. Esc on an empty one closes it; nothing is sent.
|
|
788
|
+
- **Move and remove** are `ListEditor`'s buttons, with its names ("Move
|
|
789
|
+
item 2 up", "Remove item 2"). Each saves at once; whatever is open is
|
|
790
|
+
saved first. While it saves, the buttons wait and "Saving…" shows under
|
|
791
|
+
the list; the caret follows the item it moved. A refusal leaves the
|
|
792
|
+
list as it was, with the reason under it.
|
|
793
|
+
- **One save per change, of the whole list**, built on the record's
|
|
794
|
+
latest list when it's sent: `save` is given a change, not a list, and
|
|
795
|
+
hands it to the single-field save as a function of the record, so it
|
|
796
|
+
never undoes a tick or a change still saving.
|
|
797
|
+
- **The form's rules**, the whole list's schema, run on the list each
|
|
798
|
+
change would make: a message about an item shows under that item, one
|
|
799
|
+
about the list (too many items) under the row being added.
|
|
800
|
+
- `content` shows above the items (the done-count) and `emptyLabel` in
|
|
801
|
+
place of them when there are none.
|
|
802
|
+
|
|
803
|
+
```tsx
|
|
804
|
+
const checklist = editInPlace<ChecklistItem>({
|
|
805
|
+
kind: 'list',
|
|
806
|
+
value: widget.checklist,
|
|
807
|
+
schema: widgetFormSchema.shape.checklist, // the whole list's rule
|
|
808
|
+
save: (change) => editField.mutateAsync((current) => ({ checklist: change(current.checklist) })),
|
|
809
|
+
newItem: () => ({ text: '', done: false }),
|
|
810
|
+
itemName: (index) => `item ${index + 1}`, // as the form's ListEditor names them
|
|
811
|
+
addLabel: 'Add item',
|
|
812
|
+
renderShown: (_item, index) => <ChecklistItem widget={widget} index={index} />, // the quick action
|
|
813
|
+
renderItem: (props) => <WidgetChecklistItemFields index={props.index} … />, // the form's row
|
|
814
|
+
})
|
|
815
|
+
// The section: { title: 'Checklist', content: <ChecklistDoneCount …/>, emptyLabel: 'No items', edit: checklist }
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
The row's fields are one component shared with the form
|
|
819
|
+
(`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
|
|
820
|
+
position ("Item 2 text") as the row buttons name the item.
|
|
821
|
+
|
|
772
822
|
## 19. Stage circle for an ordered status
|
|
773
823
|
|
|
774
824
|
A status that's an ordered lifecycle (each record moves through the same
|
|
@@ -105,7 +105,7 @@ lower; say so in a comment.
|
|
|
105
105
|
|
|
106
106
|
Tones say good, bad or *notice this*. A value that is merely *different* from its
|
|
107
107
|
neighbours — a genre, a team, a category — needs a different mechanism, and
|
|
108
|
-
it is `--category-1` through `--category-
|
|
108
|
+
it is `--category-1` through `--category-12`.
|
|
109
109
|
|
|
110
110
|
They are **generic slots, not per-value tokens**. An app maps its enum to a
|
|
111
111
|
slot in its own columns file, so a new enum value costs a line there rather
|
|
@@ -120,31 +120,49 @@ const GENRE_COLOR: Record<Genre, string> = {
|
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
Tailwind needs whole class names, so map the value to a complete class —
|
|
123
|
-
never build one by interpolation, or the class won't be generated.
|
|
123
|
+
never build one by interpolation, or the class won't be generated. An app
|
|
124
|
+
that lets people pick a colour stores the slot's name (an enum of
|
|
125
|
+
`category-1` … `category-12` in its contract, with a CHECK on the column)
|
|
126
|
+
and maps each to its whole class the same way.
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
| Slots | What they are | Near info's blue |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| 1–8 | eight hues, about 25° apart | 1, 6, 8 |
|
|
131
|
+
| 9–12 | deeper shades of four of those hues: 9 teal (4's), 10 plum (5's), 11 olive (3's), 12 violet (2's) | none |
|
|
132
|
+
|
|
133
|
+
Five things about them:
|
|
126
134
|
|
|
127
135
|
- **The hues avoid red, green and amber**, which the tones already spend. A
|
|
128
136
|
category drawn in red reads as "this one is bad" even when nothing is
|
|
129
137
|
wrong. Info's blue came later, and slots 1, 6 and 8 sit near it: a table
|
|
130
138
|
that shows an info badge keeps its categories off those three, so a blue
|
|
131
|
-
genre isn't read as "notice this".
|
|
139
|
+
genre isn't read as "notice this". That still leaves nine.
|
|
132
140
|
- **They are ordered by distinctness, not by hue angle.** An app using
|
|
133
141
|
three categories gets three obviously different colours. Past about five,
|
|
134
142
|
hue alone stops separating them — slots 3 and 7 are both yellow-greens —
|
|
135
143
|
so the glyph has to carry the meaning and the colour only reinforces it.
|
|
144
|
+
- **Past eight, it's lightness, not hue.** The hues left between the tones
|
|
145
|
+
are spent, so slots 9–12 are darker shades of four of the first eight, in
|
|
146
|
+
both themes. Each is at least as far from every other slot as the first
|
|
147
|
+
eight are from each other (a unit test holds every pair to it), but a
|
|
148
|
+
deep shade beside its own hue is told apart by how dark it is, which
|
|
149
|
+
reads less readily than a hue. Use them for a set that really has more
|
|
150
|
+
than eight values, and keep a shade away from its own hue where you can
|
|
151
|
+
choose (10 isn't 5's neighbour in a list).
|
|
136
152
|
- **Tint the glyph, never fill behind text.** The label stays
|
|
137
153
|
`text-foreground`, so readability never depends on the hue. A slot used
|
|
138
154
|
as a background needs its own contrast check, which nothing here does
|
|
139
155
|
for you.
|
|
140
|
-
- **The glyph check re-measures them.** All
|
|
141
|
-
non-text minimum in both themes (recorded in
|
|
142
|
-
text contrast, not icon contrast, so the
|
|
143
|
-
draws an icon in each slot
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
- **The glyph check re-measures them.** All twelve clear the 3:1
|
|
157
|
+
non-text minimum in both themes, on the page and on a card (recorded in
|
|
158
|
+
`theme.css`). axe checks text contrast, not icon contrast, so the
|
|
159
|
+
package's Storybook glyph check draws an icon in each slot on both
|
|
160
|
+
surfaces (`patterns/CategoricalColour`, which also shows them side by
|
|
161
|
+
side) and fails a change that takes one below 3:1.
|
|
162
|
+
|
|
163
|
+
If you find yourself wanting a thirteenth, that is a sign the column should
|
|
164
|
+
be showing a shape or a label rather than more colours: there is no more
|
|
165
|
+
room in hue or lightness that stays readable.
|
|
148
166
|
|
|
149
167
|
## Typography
|
|
150
168
|
|
|
@@ -81,7 +81,8 @@ per list; delete the section if there are none.
|
|
|
81
81
|
- On the view: always shown: the done-count, then each item in order (a
|
|
82
82
|
`url` item field opens in a new tab). Read-only, unless the View
|
|
83
83
|
screen's `Quick actions` names the list: then its yes/no item field is a
|
|
84
|
-
checkbox on each item, ticked in place.
|
|
84
|
+
checkbox on each item, ticked in place. If its `Edit in place` names
|
|
85
|
+
the list, its items are also added, edited, moved and removed there.
|
|
85
86
|
|
|
86
87
|
| Field | Label | Type | Required | Options / rules |
|
|
87
88
|
|---|---|---|---|---|
|
|
@@ -162,12 +163,12 @@ except the quick actions, if any.
|
|
|
162
163
|
- Edit in place (optional; delete if none): the fields that turn into
|
|
163
164
|
their form control where the view shows them and save when you leave
|
|
164
165
|
them (cell pattern 18): the title, a badge field, a field row, a long
|
|
165
|
-
text
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
named here, or under Quick actions,
|
|
169
|
-
server enforces on one (its refusal
|
|
170
|
-
side effect:
|
|
166
|
+
text, a sub-records list (its items added, edited, moved and removed on
|
|
167
|
+
the view). Text, long text, a single choice, an integer or a rating, a
|
|
168
|
+
yes/no, a single or multi reference, a sub-records list. Never a
|
|
169
|
+
computed field. Every value not named here, or under Quick actions,
|
|
170
|
+
stays read-only. Say any rule the server enforces on one (its refusal
|
|
171
|
+
shows under the field) and any side effect:
|
|
171
172
|
|
|
172
173
|
Edit in place: title, status (the server refuses Finished while the
|
|
173
174
|
checklist has open items), dueDate, notes
|
|
@@ -43,15 +43,18 @@ function TableSkeleton({ columnCount }) {
|
|
|
43
43
|
// bleed-through a solid background avoids. On an even row it's the stripe
|
|
44
44
|
// pre-mixed onto the page (--table-stripe), so the stripe runs the whole
|
|
45
45
|
// row's width (issue #92); `:where()` keeps that rule's specificity at the
|
|
46
|
-
// background's, so the hover below still wins on every row.
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
46
|
+
// background's, so the first column's hover below still wins on every row.
|
|
47
|
+
const PINNED_CELL_CLASS = 'z-10 bg-background [:where(tbody>tr:nth-child(even))>&]:bg-(--table-stripe)';
|
|
48
|
+
// The pinned first cell carries the hover highlight. --accent is only ~3%
|
|
49
|
+
// off the page background, so a solid left edge marker carries the signal;
|
|
50
|
+
// an inset shadow rather than a border, so nothing reflows.
|
|
51
|
+
const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:bg-accent ` +
|
|
52
|
+
'group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]';
|
|
53
|
+
// The right-edge mirror, opaque for the same reason, but with no hover
|
|
54
|
+
// highlight (issue #103): it holds the row's actions, which name nothing,
|
|
55
|
+
// so lighting it too read as a second selected thing. Only the first
|
|
56
|
+
// column marks the hovered row.
|
|
57
|
+
const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
|
|
55
58
|
// A pinned first column's content, capped below md at 45% of the table's
|
|
56
59
|
// scroll area (`cqw`: the container is an inline-size query container)
|
|
57
60
|
// and wrapping, so a long title can't widen the column past the screen
|
|
@@ -189,7 +192,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
189
192
|
: sortDirection === 'desc'
|
|
190
193
|
? 'descending'
|
|
191
194
|
: 'none', children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
|
|
192
|
-
}) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn(
|
|
195
|
+
}) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
|
|
193
196
|
index === row.getVisibleCells().length - 1 &&
|
|
194
197
|
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))) })] }), 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, {}) })] })] })] }))] }));
|
|
195
198
|
}
|
|
@@ -36,6 +36,8 @@ export type OpenFieldState = {
|
|
|
36
36
|
/** A problem the control reports itself (rich text that can't save as it stands). */
|
|
37
37
|
problem: string | null;
|
|
38
38
|
};
|
|
39
|
+
/** Why a save failed, in words to show beside what was being saved. */
|
|
40
|
+
export declare function messageOf(error: unknown): string;
|
|
39
41
|
export declare class EditInPlaceStore {
|
|
40
42
|
private configs;
|
|
41
43
|
private states;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { refusalReason } from '../../hooks/use-record-update.js';
|
|
2
2
|
const CLOSED = { status: 'closed' };
|
|
3
3
|
const sameAsJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
4
|
-
|
|
4
|
+
/** Why a save failed, in words to show beside what was being saved. */
|
|
5
|
+
export function messageOf(error) {
|
|
5
6
|
if (error && typeof error === 'object' && 'kind' in error)
|
|
6
7
|
return refusalReason(error);
|
|
7
8
|
if (error instanceof Error)
|
|
@@ -44,6 +44,7 @@ type SafeParseResult<TValue> = {
|
|
|
44
44
|
error: {
|
|
45
45
|
issues: readonly {
|
|
46
46
|
message: string;
|
|
47
|
+
path?: readonly PropertyKey[];
|
|
47
48
|
}[];
|
|
48
49
|
};
|
|
49
50
|
};
|
|
@@ -82,6 +83,67 @@ export type EditInPlaceOptions<TDraft, TValue> = {
|
|
|
82
83
|
/** Whether a draft is still the saved value. Defaults to comparing as JSON. */
|
|
83
84
|
isEqual?: (a: TDraft, b: TDraft) => boolean;
|
|
84
85
|
};
|
|
86
|
+
/** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
|
|
87
|
+
export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
88
|
+
/** The item's position; for the item being added, the one it will take (the list's length). */
|
|
89
|
+
index: number;
|
|
90
|
+
/** The item being added at the end, not one already in the list. */
|
|
91
|
+
isNew: boolean;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* A list of sub-items edited in place (a checklist, a list of links), on
|
|
95
|
+
* a `content` section of EntityView. Make one with `editInPlace({ kind:
|
|
96
|
+
* 'list', ... })`. Every item reads as `renderShown` (a quick action, such
|
|
97
|
+
* as its done box, still works there); clicking it or its pencil turns it
|
|
98
|
+
* into `renderItem`, the form's own row. "Add" at the end opens a new item;
|
|
99
|
+
* move up, move down and remove are ListEditor's buttons. Each change is one
|
|
100
|
+
* save of the whole list.
|
|
101
|
+
*/
|
|
102
|
+
export type EditInPlaceListOptions<TItem> = {
|
|
103
|
+
kind: 'list';
|
|
104
|
+
/** The saved list. */
|
|
105
|
+
value: readonly TItem[];
|
|
106
|
+
/**
|
|
107
|
+
* The item's own fields while it's edited: the form's row (what its
|
|
108
|
+
* ListEditor's `renderItem` shows), as controls over `props.value`.
|
|
109
|
+
*/
|
|
110
|
+
renderItem: (props: EditListItemProps<TItem>) => ReactNode;
|
|
111
|
+
/** How the item reads when it isn't being edited. Its own controls (a done box) keep working. */
|
|
112
|
+
renderShown: (item: TItem, index: number) => ReactNode;
|
|
113
|
+
/** A blank item, what "Add" starts from. */
|
|
114
|
+
newItem: () => TItem;
|
|
115
|
+
/** How the buttons name an item, as on the form: (i) => `item ${i + 1}` gives "Move item 2 up". */
|
|
116
|
+
itemName: (index: number) => string;
|
|
117
|
+
/** The Add button's text, e.g. "Add item". */
|
|
118
|
+
addLabel: string;
|
|
119
|
+
/**
|
|
120
|
+
* The form's schema for the whole list (`widgetFormSchema.shape.checklist`).
|
|
121
|
+
* Each change is checked as the list it would make; a message about an
|
|
122
|
+
* item shows under that item.
|
|
123
|
+
*/
|
|
124
|
+
schema?: {
|
|
125
|
+
safeParse: (value: unknown) => SafeParseResult<TItem[]>;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Saves a change to the list: call the record's single-field save with
|
|
129
|
+
* the change applied to the record's latest list, so it builds on any
|
|
130
|
+
* save still in flight (a tick, a move), e.g.
|
|
131
|
+
* `(change) => edit.mutateAsync((widget) => ({ checklist: change(widget.checklist) }))`.
|
|
132
|
+
* Rejects with the AppError; a 422's message shows where the change was made.
|
|
133
|
+
*/
|
|
134
|
+
save: (change: (current: TItem[]) => TItem[]) => Promise<unknown>;
|
|
135
|
+
};
|
|
136
|
+
/** A list marked editable in place. Make one with `editInPlace({ kind: 'list', ... })`. */
|
|
137
|
+
export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save'> & {
|
|
138
|
+
renderItem: (props: EditListItemProps<unknown>) => ReactNode;
|
|
139
|
+
renderShown: (item: unknown, index: number) => ReactNode;
|
|
140
|
+
schema?: {
|
|
141
|
+
safeParse: (value: unknown) => SafeParseResult<unknown[]>;
|
|
142
|
+
};
|
|
143
|
+
save: (change: (current: unknown[]) => unknown[]) => Promise<unknown>;
|
|
144
|
+
};
|
|
145
|
+
/** Marks a list editable in place: its items added, edited, removed and moved on the view. */
|
|
146
|
+
export declare function editInPlace<TItem>(options: EditInPlaceListOptions<TItem>): EditInPlaceList;
|
|
85
147
|
/** Marks a field editable in place, typed end to end: draft → schema → save. */
|
|
86
148
|
export declare function editInPlace<TDraft, TValue = TDraft>(options: EditInPlaceOptions<TDraft, TValue>): EditInPlace;
|
|
87
149
|
export declare const EditInPlaceContext: import("react").Context<EditInPlaceStore | null>;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Editing in place on a record's view: what a field passes to EntityView
|
|
2
2
|
// (or to an EditableValue in its header) to become editable where it's
|
|
3
3
|
// shown. The rules live in edit-in-place-store.ts; the markup in
|
|
4
|
-
// editable-value.tsx
|
|
4
|
+
// editable-value.tsx, and a list's in editable-list.tsx. Design language:
|
|
5
|
+
// cell-patterns.md "Editable value" and "Editable list".
|
|
5
6
|
import { createContext } from 'react';
|
|
6
|
-
/** Marks a field editable in place, typed end to end: draft → schema → save. */
|
|
7
7
|
export function editInPlace(options) {
|
|
8
8
|
return options;
|
|
9
9
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A list of sub-items edited in place on a record's view (cell-patterns.md
|
|
3
|
+
// "Editable list"): editInPlace's `list` kind, on a `content` section of
|
|
4
|
+
// EntityView. Built on ListEditor, so the rows and their buttons are the
|
|
5
|
+
// form's, with the same names ("Move item 2 up", "Remove item 2").
|
|
6
|
+
//
|
|
7
|
+
// - Each item reads as the app's `renderShown`, whose own controls (a
|
|
8
|
+
// done box) keep working. Clicking it, or its pencil, turns it into the
|
|
9
|
+
// form's row (`renderItem`), an EditableValue like any field: leaving or
|
|
10
|
+
// Enter saves, Esc gives up, one field open at a time.
|
|
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.
|
|
13
|
+
// - Move and remove save at once. Whatever is open is saved first; while
|
|
14
|
+
// the change saves, the buttons wait, "Saving…" shows under the list,
|
|
15
|
+
// and a refusal leaves the list as it was with the reason under it.
|
|
16
|
+
// - Every change is one save of the whole list, built from the record's
|
|
17
|
+
// latest list when it's sent (`save` takes a change, not a list), so it
|
|
18
|
+
// never undoes a tick or another change still saving.
|
|
19
|
+
import { PlusIcon } from 'lucide-react';
|
|
20
|
+
import { useContext, useEffect, useId, useRef, useState } from 'react';
|
|
21
|
+
import { Button } from '../ui/button.js';
|
|
22
|
+
import { FieldError } from '../ui/field.js';
|
|
23
|
+
import { Spinner } from '../ui/spinner.js';
|
|
24
|
+
import { EditInPlaceContext } from './edit-in-place.js';
|
|
25
|
+
import { messageOf } from './edit-in-place-store.js';
|
|
26
|
+
import { EditableField } from './editable-value.js';
|
|
27
|
+
import { ListEditor } from './list-editor.js';
|
|
28
|
+
const sameItem = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
29
|
+
// Where the item shown at `index` is in the latest list: still there, or
|
|
30
|
+
// wherever a change saved before this one moved it, or, if it isn't
|
|
31
|
+
// anywhere as shown (it was itself just changed), the same position.
|
|
32
|
+
function locate(current, item, index) {
|
|
33
|
+
if (index < current.length && sameItem(current[index], item))
|
|
34
|
+
return index;
|
|
35
|
+
const found = current.findIndex((other) => sameItem(other, item));
|
|
36
|
+
return found === -1 ? index : found;
|
|
37
|
+
}
|
|
38
|
+
// The list's schema, run on the list this draft would make. A message
|
|
39
|
+
// about this item comes first, then one about the list as a whole (too
|
|
40
|
+
// many items).
|
|
41
|
+
function checkItem(edit, items, index, draft) {
|
|
42
|
+
if (!edit.schema)
|
|
43
|
+
return { success: true, data: draft };
|
|
44
|
+
const list = index < items.length ? items.map((item, i) => (i === index ? draft : item)) : [...items, draft];
|
|
45
|
+
const result = edit.schema.safeParse(list);
|
|
46
|
+
if (result.success)
|
|
47
|
+
return { success: true, data: result.data[index] };
|
|
48
|
+
const { issues } = result.error;
|
|
49
|
+
const issue = issues.find(({ path }) => path?.[0] === index) ?? issues.find(({ path }) => !path?.length) ?? issues[0];
|
|
50
|
+
return { success: false, error: { issues: issue ? [issue] : [] } };
|
|
51
|
+
}
|
|
52
|
+
export function EditableList({ label, edit, emptyLabel }) {
|
|
53
|
+
const store = useContext(EditInPlaceContext);
|
|
54
|
+
if (!store)
|
|
55
|
+
throw new Error('A list edited in place works inside an <EntityView>, which runs editing in place.');
|
|
56
|
+
const items = edit.value;
|
|
57
|
+
const statusId = useId();
|
|
58
|
+
const [status, setStatus] = useState({ saving: false, error: null });
|
|
59
|
+
// The buttons are disabled while a change saves, which drops the caret.
|
|
60
|
+
// Once the list shows the outcome it goes to the first of these buttons
|
|
61
|
+
// (by name) that's there and enabled, else to Add.
|
|
62
|
+
const root = useRef(null);
|
|
63
|
+
const focusNext = useRef(null);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const names = focusNext.current;
|
|
66
|
+
if (status.saving || !names)
|
|
67
|
+
return;
|
|
68
|
+
focusNext.current = null;
|
|
69
|
+
const buttons = [...(root.current?.querySelectorAll('button:not(:disabled)') ?? [])];
|
|
70
|
+
const target = names.map((name) => buttons.find((button) => button.getAttribute('aria-label') === name)).find(Boolean);
|
|
71
|
+
(target ?? root.current?.querySelector('[data-list-add]:not(:disabled)'))?.focus();
|
|
72
|
+
}, [items, status.saving]);
|
|
73
|
+
// Move and remove: save the open field first (if it can't save, it stays
|
|
74
|
+
// and nothing else happens), then the change.
|
|
75
|
+
const act = async (change, focusAfter, focusIfRefused) => {
|
|
76
|
+
if (status.saving)
|
|
77
|
+
return;
|
|
78
|
+
const open = store.open;
|
|
79
|
+
if (open !== null && !(await store.commit(open)))
|
|
80
|
+
return;
|
|
81
|
+
setStatus({ saving: true, error: null });
|
|
82
|
+
try {
|
|
83
|
+
await edit.save(change);
|
|
84
|
+
focusNext.current = focusAfter;
|
|
85
|
+
setStatus({ saving: false, error: null });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
focusNext.current = focusIfRefused;
|
|
89
|
+
setStatus({ saving: false, error: messageOf(error) });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const move = (from, to) => {
|
|
93
|
+
const direction = to < from ? 'up' : 'down';
|
|
94
|
+
const other = direction === 'up' ? 'down' : 'up';
|
|
95
|
+
void act((current) => {
|
|
96
|
+
const at = locate(current, items[from], from);
|
|
97
|
+
const target = at + (to - from);
|
|
98
|
+
if (target < 0 || target >= current.length)
|
|
99
|
+
return current;
|
|
100
|
+
const next = [...current];
|
|
101
|
+
next.splice(target, 0, ...next.splice(at, 1));
|
|
102
|
+
return next;
|
|
103
|
+
}, [`Move ${edit.itemName(to)} ${direction}`, `Move ${edit.itemName(to)} ${other}`], [`Move ${edit.itemName(from)} ${direction}`]);
|
|
104
|
+
};
|
|
105
|
+
const remove = (index) => void act((current) => {
|
|
106
|
+
const at = locate(current, items[index], index);
|
|
107
|
+
return current.filter((_, i) => i !== at);
|
|
108
|
+
}, [`Remove ${edit.itemName(index)}`, `Remove ${edit.itemName(index - 1)}`], [`Remove ${edit.itemName(index)}`]);
|
|
109
|
+
// An item, edited as the form's row.
|
|
110
|
+
const itemEdit = (index) => ({
|
|
111
|
+
kind: 'text',
|
|
112
|
+
value: items[index],
|
|
113
|
+
control: (props) => edit.renderItem({ ...props, index, isNew: false }),
|
|
114
|
+
schema: { safeParse: (draft) => checkItem(edit, items, index, draft) },
|
|
115
|
+
save: (item) => edit.save((current) => {
|
|
116
|
+
const at = locate(current, items[index], index);
|
|
117
|
+
return current.map((other, i) => (i === at ? item : other));
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
// The item being added, at the end.
|
|
121
|
+
const addEdit = {
|
|
122
|
+
kind: 'text',
|
|
123
|
+
value: edit.newItem(),
|
|
124
|
+
control: (props) => edit.renderItem({ ...props, index: items.length, isNew: true }),
|
|
125
|
+
schema: { safeParse: (draft) => checkItem(edit, items, items.length, draft) },
|
|
126
|
+
save: (item) => edit.save((current) => [...current, item]),
|
|
127
|
+
};
|
|
128
|
+
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 })) })] }));
|
|
129
|
+
}
|
|
@@ -11,7 +11,19 @@ export type EditableValueProps = {
|
|
|
11
11
|
layout?: 'inline' | 'block';
|
|
12
12
|
className?: string;
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
/** EditableValue's own props, plus what a list's rows and its Add box need (editable-list.tsx). */
|
|
15
|
+
export type EditableFieldProps = EditableValueProps & {
|
|
16
|
+
/**
|
|
17
|
+
* The closed field's own trigger, instead of the value and its pencil:
|
|
18
|
+
* the list's Add button. Given what opens the field; its button is where
|
|
19
|
+
* the caret goes back to.
|
|
20
|
+
*/
|
|
21
|
+
trigger?: (start: () => void) => ReactNode;
|
|
22
|
+
/** Opens again, empty, once a save from the keyboard succeeds: adding one item after another. */
|
|
23
|
+
reopenAfterKeyboardSave?: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function EditableValue(props: EditableValueProps): import("react").JSX.Element;
|
|
26
|
+
export declare function EditableField({ label, edit, children, layout, className, trigger, reopenAfterKeyboardSave, }: EditableFieldProps): import("react").JSX.Element;
|
|
15
27
|
/**
|
|
16
28
|
* While a field holds something unsaved, leaving asks first: an in-app
|
|
17
29
|
* link or Back (React Router's blocker, so the app needs a data router),
|
|
@@ -21,12 +21,14 @@ import { FieldError } from '../ui/field.js';
|
|
|
21
21
|
import { Spinner } from '../ui/spinner.js';
|
|
22
22
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
23
23
|
// What a click on the shown value must leave alone: links navigate,
|
|
24
|
-
// buttons do their own thing.
|
|
25
|
-
const INTERACTIVE = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
|
|
24
|
+
// buttons and boxes do their own thing (a list item's done box ticks it).
|
|
25
|
+
const INTERACTIVE = 'a[href], button, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="switch"]';
|
|
26
26
|
// Where the caret goes once the control shows: the first match, in this
|
|
27
|
-
// order (a picker's text box before its chips' remove buttons).
|
|
27
|
+
// order (a picker's text box before its chips' remove buttons). A box's
|
|
28
|
+
// own native input (a checkbox's, hidden and out of the tab order) isn't
|
|
29
|
+
// a place to type: a list item's text box comes first.
|
|
28
30
|
const FOCUS_ORDER = [
|
|
29
|
-
'input:not([type="hidden"]), textarea, [contenteditable="true"]',
|
|
31
|
+
'input:not([type="hidden"]):not([tabindex="-1"]):not([aria-hidden="true"]), textarea, [contenteditable="true"]',
|
|
30
32
|
'[role="combobox"], [role="switch"], button, [tabindex]:not([tabindex="-1"])',
|
|
31
33
|
];
|
|
32
34
|
// Focus moving here doesn't save the field: a link that leaves the page
|
|
@@ -42,7 +44,10 @@ function useStore() {
|
|
|
42
44
|
throw new Error('<EditableValue> works inside an <EntityView>, which runs editing in place.');
|
|
43
45
|
return store;
|
|
44
46
|
}
|
|
45
|
-
export function EditableValue(
|
|
47
|
+
export function EditableValue(props) {
|
|
48
|
+
return _jsx(EditableField, { ...props });
|
|
49
|
+
}
|
|
50
|
+
export function EditableField({ label, edit, children, layout = 'block', className, trigger, reopenAfterKeyboardSave = false, }) {
|
|
46
51
|
const store = useStore();
|
|
47
52
|
const key = useId();
|
|
48
53
|
const controlId = `${key}-control`;
|
|
@@ -55,6 +60,8 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
|
|
|
55
60
|
useEffect(() => store.register(key, () => config.current), [store, key]);
|
|
56
61
|
const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
|
|
57
62
|
const editButton = useRef(null);
|
|
63
|
+
// Holds a trigger (the list's Add): its button is the one the caret goes back to.
|
|
64
|
+
const triggerBox = useRef(null);
|
|
58
65
|
const container = useRef(null);
|
|
59
66
|
// Set by a keyboard save or Esc: the caret goes back to the edit button.
|
|
60
67
|
const refocus = useRef(false);
|
|
@@ -76,7 +83,7 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
|
|
|
76
83
|
// from a list), so the keyboard never loses its place.
|
|
77
84
|
if (!isOpen && wasOpen.current && (refocus.current || document.activeElement === document.body)) {
|
|
78
85
|
refocus.current = false;
|
|
79
|
-
editButton.current?.focus();
|
|
86
|
+
(editButton.current ?? triggerBox.current?.querySelector('button'))?.focus();
|
|
80
87
|
}
|
|
81
88
|
wasOpen.current = isOpen;
|
|
82
89
|
}, [isOpen]);
|
|
@@ -102,6 +109,19 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
|
|
|
102
109
|
void store.commit(key, value);
|
|
103
110
|
}, [store, key]);
|
|
104
111
|
const cancel = useCallback(() => store.cancel(key), [store, key]);
|
|
112
|
+
// Enter or Ctrl/Cmd+Enter: the caret goes back to the edit button, or
|
|
113
|
+
// the field opens again for the next one.
|
|
114
|
+
const commitFromKeyboard = () => {
|
|
115
|
+
window.clearTimeout(blurTimer.current);
|
|
116
|
+
refocus.current = true;
|
|
117
|
+
void store.commit(key).then((closed) => {
|
|
118
|
+
if (!closed || !reopenAfterKeyboardSave)
|
|
119
|
+
return;
|
|
120
|
+
// The caret stays in (or comes back to) the field opening again.
|
|
121
|
+
refocus.current = false;
|
|
122
|
+
void store.start(key);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
105
125
|
const onShownClick = (event) => {
|
|
106
126
|
if (event.target.closest(INTERACTIVE))
|
|
107
127
|
return;
|
|
@@ -120,13 +140,11 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
|
|
|
120
140
|
}
|
|
121
141
|
else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
122
142
|
event.preventDefault();
|
|
123
|
-
|
|
124
|
-
commit();
|
|
143
|
+
commitFromKeyboard();
|
|
125
144
|
}
|
|
126
145
|
else if (event.key === 'Enter' && edit.kind === 'text' && !event.shiftKey && !event.altKey) {
|
|
127
146
|
event.preventDefault();
|
|
128
|
-
|
|
129
|
-
commit();
|
|
147
|
+
commitFromKeyboard();
|
|
130
148
|
}
|
|
131
149
|
};
|
|
132
150
|
// Leaving the field saves it. Focus moving into the control's own list
|
|
@@ -142,6 +160,8 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
|
|
|
142
160
|
};
|
|
143
161
|
const onFocus = () => window.clearTimeout(blurTimer.current);
|
|
144
162
|
if (state.status === 'closed') {
|
|
163
|
+
if (trigger)
|
|
164
|
+
return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
|
|
145
165
|
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, {}) })] }));
|
|
146
166
|
}
|
|
147
167
|
const saving = state.status === 'saving';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { AppError } from '../../api/contracts.js';
|
|
3
|
-
import { type EditInPlace } from './edit-in-place.js';
|
|
3
|
+
import { type EditInPlace, type EditInPlaceList } from './edit-in-place.js';
|
|
4
4
|
export type EntityViewField = {
|
|
5
5
|
label: string;
|
|
6
6
|
/**
|
|
@@ -29,8 +29,14 @@ export type EntityViewSection = ({
|
|
|
29
29
|
title: string;
|
|
30
30
|
content: ReactNode;
|
|
31
31
|
emptyLabel?: string;
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The block is editable in place, named by the section's title: long
|
|
34
|
+
* text (`editInPlace({ kind: 'long-text', ... })`), or a list of
|
|
35
|
+
* sub-items (`editInPlace({ kind: 'list', ... })`). A list shows its
|
|
36
|
+
* own items, with `content` above them (a done-count, or nothing),
|
|
37
|
+
* and `emptyLabel` in place of the items when there are none.
|
|
38
|
+
*/
|
|
39
|
+
edit?: EditInPlace | EditInPlaceList;
|
|
34
40
|
}) & {
|
|
35
41
|
/**
|
|
36
42
|
* Where the section goes in the `rail` layout: `rail` for the short ones
|
|
@@ -23,7 +23,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
23
23
|
// saves when you leave it. This component runs it (one field open at a
|
|
24
24
|
// time, the leave-page prompt); EditableValue is each value's side, and a
|
|
25
25
|
// header badge uses it directly. A field not marked renders exactly as
|
|
26
|
-
// before. Edit, in `actions`, stays for everything else.
|
|
26
|
+
// before. Edit, in `actions`, stays for everything else. A sub-item list
|
|
27
|
+
// edits in place too (editInPlace's `list` kind, editable-list.tsx): its
|
|
28
|
+
// items added, edited, moved and removed on the page.
|
|
27
29
|
//
|
|
28
30
|
// Two layouts. `column` (the default): every section in one column capped
|
|
29
31
|
// at max-w-4xl. `rail`, for a record with long content: the page takes the
|
|
@@ -43,6 +45,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTi
|
|
|
43
45
|
import { Skeleton } from '../ui/skeleton.js';
|
|
44
46
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
45
47
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
48
|
+
import { EditableList } from './editable-list.js';
|
|
46
49
|
import { EditableValue, LeaveGuard } from './editable-value.js';
|
|
47
50
|
const DEFAULT_EMPTY_LABEL = 'Not set';
|
|
48
51
|
function isEmptyValue(value) {
|
|
@@ -72,7 +75,7 @@ function FieldRow({ field, inRail }) {
|
|
|
72
75
|
}
|
|
73
76
|
function ViewSection({ section, inRail = false }) {
|
|
74
77
|
const headingId = useId();
|
|
75
|
-
return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: cn('flex flex-col divide-y divide-border', inRail && '@container/fields'), children: section.fields.map((field) => (_jsx(FieldRow, { field: field, inRail: inRail }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
|
|
78
|
+
return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: cn('flex flex-col divide-y divide-border', inRail && '@container/fields'), children: section.fields.map((field) => (_jsx(FieldRow, { field: field, inRail: inRail }, field.label))) })) : section.edit?.kind === 'list' ? (_jsxs("div", { className: "flex min-w-0 flex-col gap-2 type-body break-words text-foreground", children: [!isEmptyValue(section.content) && section.content, _jsx(EditableList, { label: section.title, edit: section.edit, emptyLabel: section.emptyLabel ?? DEFAULT_EMPTY_LABEL })] })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
|
|
76
79
|
}
|
|
77
80
|
// The rail layout's columns. The view is a container, so the switch to
|
|
78
81
|
// two columns follows the width the page actually has (the sidebar open
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, 'aria-label': ariaLabel, }: {
|
|
2
|
+
export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, 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
|
-
|
|
9
|
+
/** The Add button's action. Not needed with `add`. */
|
|
10
|
+
onAdd?: () => void;
|
|
10
11
|
onRemove: (index: number) => void;
|
|
11
12
|
onMove: (from: number, to: number) => void;
|
|
12
13
|
/**
|
|
@@ -20,6 +21,10 @@ export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove,
|
|
|
20
21
|
emptyText?: string;
|
|
21
22
|
/** The most items allowed; Add is disabled once reached. */
|
|
22
23
|
max?: number;
|
|
24
|
+
/** In place of the Add button: what adds an item (editing in place's Add box). */
|
|
25
|
+
add?: ReactNode;
|
|
26
|
+
/** The row buttons and Add can't be used (a change is saving). */
|
|
27
|
+
disabled?: boolean;
|
|
23
28
|
/** Names the list (e.g. the field's label). */
|
|
24
29
|
'aria-label'?: string;
|
|
25
30
|
}): import("react").JSX.Element;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react';
|
|
3
3
|
import { Button } from '../ui/button.js';
|
|
4
|
-
export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, 'aria-label': ariaLabel, }) {
|
|
4
|
+
export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
|
|
5
5
|
const atMax = max !== undefined && items.length >= max;
|
|
6
6
|
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
7
|
const name = itemName(index);
|
|
8
|
-
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) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} up`, 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: index === items.length - 1, onClick: () => onMove(index, index + 1), children: _jsx(ArrowDownIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }, item.id));
|
|
9
|
-
}) })), _jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, disabled: atMax, children: [_jsx(PlusIcon, {}), addLabel] }) })] }));
|
|
8
|
+
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) }), _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, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }, item.id));
|
|
9
|
+
}) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
|
|
10
10
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
|
3
3
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
4
4
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
5
5
|
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
6
|
-
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
|
|
6
|
+
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
7
7
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
8
8
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
9
9
|
export { ErrorState } from './components/app/error-state.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.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": {
|
package/styles/index.css
CHANGED
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
--color-category-6: var(--category-6);
|
|
58
58
|
--color-category-7: var(--category-7);
|
|
59
59
|
--color-category-8: var(--category-8);
|
|
60
|
+
--color-category-9: var(--category-9);
|
|
61
|
+
--color-category-10: var(--category-10);
|
|
62
|
+
--color-category-11: var(--category-11);
|
|
63
|
+
--color-category-12: var(--category-12);
|
|
60
64
|
--color-accent-foreground: var(--accent-foreground);
|
|
61
65
|
--color-accent: var(--accent);
|
|
62
66
|
--color-muted-foreground: var(--muted-foreground);
|
package/styles/theme.css
CHANGED
|
@@ -72,12 +72,13 @@
|
|
|
72
72
|
* later: a table using info keeps its categories off those three. They are ordered by distinctness, not by hue angle, so an app
|
|
73
73
|
* using only the first three gets three obviously different colours.
|
|
74
74
|
* Past about five, hue alone stops separating them and the glyph has to
|
|
75
|
-
* carry the meaning; see docs/design-language.md.
|
|
75
|
+
* carry the meaning; see docs/design-language.md. Twelve slots in all.
|
|
76
76
|
*
|
|
77
77
|
* Ratios are against the page, measured (WCAG 2.x, sRGB), and all clear
|
|
78
|
-
* the 3:1 non-text minimum in both themes with room to spare.
|
|
79
|
-
*
|
|
80
|
-
* re-measures
|
|
78
|
+
* the 3:1 non-text minimum in both themes with room to spare (4.5:1 at
|
|
79
|
+
* the least, slots 9-12 on the dark card). axe does NOT check icon
|
|
80
|
+
* contrast; the Storybook glyph check re-measures every slot on the
|
|
81
|
+
* page and on a card (design-language/CategoricalColour).
|
|
81
82
|
*/
|
|
82
83
|
--category-1-700: oklch(0.5 0.15 240);
|
|
83
84
|
--category-1-400: oklch(0.78 0.12 240);
|
|
@@ -95,6 +96,24 @@
|
|
|
95
96
|
--category-7-400: oklch(0.78 0.12 120);
|
|
96
97
|
--category-8-700: oklch(0.5 0.15 215);
|
|
97
98
|
--category-8-400: oklch(0.78 0.12 215);
|
|
99
|
+
/*
|
|
100
|
+
* Slots 9-12 are a second channel, not new hues: the hues left between
|
|
101
|
+
* the tones' are already spent ~25 degrees apart, so these are deeper
|
|
102
|
+
* shades of the four that sit furthest from info (teal, plum, olive,
|
|
103
|
+
* violet), darker than their slot in both themes. Each is at least as
|
|
104
|
+
* far from its nearest slot (OKLab distance) as slots 1-8 are from each
|
|
105
|
+
* other; tests/category-slots.test.ts holds every pair to that. A deep
|
|
106
|
+
* shade of the blues or the yellow-green fell to half that distance, so
|
|
107
|
+
* twelve is where it stops. Chroma is the most each holds in sRGB.
|
|
108
|
+
*/
|
|
109
|
+
--category-9-700: oklch(0.36 0.06 190);
|
|
110
|
+
--category-9-400: oklch(0.62 0.1 190);
|
|
111
|
+
--category-10-700: oklch(0.36 0.12 330);
|
|
112
|
+
--category-10-400: oklch(0.62 0.15 330);
|
|
113
|
+
--category-11-700: oklch(0.36 0.07 95);
|
|
114
|
+
--category-11-400: oklch(0.62 0.12 95);
|
|
115
|
+
--category-12-700: oklch(0.36 0.14 300);
|
|
116
|
+
--category-12-400: oklch(0.62 0.17 300);
|
|
98
117
|
--blue-500: oklch(0.488 0.243 264.376);
|
|
99
118
|
|
|
100
119
|
--radius-base: 0.625rem;
|
|
@@ -198,6 +217,10 @@
|
|
|
198
217
|
--category-6: var(--category-6-700);
|
|
199
218
|
--category-7: var(--category-7-700);
|
|
200
219
|
--category-8: var(--category-8-700);
|
|
220
|
+
--category-9: var(--category-9-700);
|
|
221
|
+
--category-10: var(--category-10-700);
|
|
222
|
+
--category-11: var(--category-11-700);
|
|
223
|
+
--category-12: var(--category-12-700);
|
|
201
224
|
/* The zebra stripe (table.tsx: bg-muted/40 on even rows) mixed onto the
|
|
202
225
|
page, opaque: what a pinned table cell paints on an even row, so it
|
|
203
226
|
matches the row without letting scrolled columns show through. Mixed
|
|
@@ -274,6 +297,10 @@
|
|
|
274
297
|
--category-6: var(--category-6-400);
|
|
275
298
|
--category-7: var(--category-7-400);
|
|
276
299
|
--category-8: var(--category-8-400);
|
|
300
|
+
--category-9: var(--category-9-400);
|
|
301
|
+
--category-10: var(--category-10-400);
|
|
302
|
+
--category-11: var(--category-11-400);
|
|
303
|
+
--category-12: var(--category-12-400);
|
|
277
304
|
--table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
|
|
278
305
|
--border: var(--white-a10);
|
|
279
306
|
--input: var(--white-a15);
|