@tristan2828/ui-foundation 4.3.1 → 4.4.1
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 +51 -3
- package/dist/components/app/data-table.js +2 -2
- package/dist/components/app/edit-in-place-store.d.ts +76 -5
- package/dist/components/app/edit-in-place-store.js +237 -19
- package/dist/components/app/edit-in-place.d.ts +18 -0
- package/dist/components/app/editable-value.d.ts +6 -1
- package/dist/components/app/editable-value.js +111 -21
- package/dist/components/app/entity-view.js +6 -4
- package/dist/components/app/escape-passthrough.d.ts +2 -0
- package/dist/components/app/escape-passthrough.js +13 -0
- package/dist/components/app/milkdown-editor.js +5 -0
- package/package.json +1 -1
|
@@ -656,8 +656,9 @@ A value on the record's view that turns into **the form's own control**
|
|
|
656
656
|
where it's shown, and saves when you leave it: a title, a status, a
|
|
657
657
|
price, notes, a list of linked records. Widget's view is the reference
|
|
658
658
|
(`src/routes/widgets/widget-view.tsx`): Name (the title), Status (its
|
|
659
|
-
header badge), Price, Description (rich text) and Extra
|
|
660
|
-
the controls in `widget-fields.tsx`, the same ones the
|
|
659
|
+
header badge), Price, Description (rich text that autosaves) and Extra
|
|
660
|
+
Categories, with the controls in `widget-fields.tsx`, the same ones the
|
|
661
|
+
form uses.
|
|
661
662
|
|
|
662
663
|
The rule behind every part of it: **nothing typed is ever lost.**
|
|
663
664
|
|
|
@@ -684,7 +685,7 @@ Save button, no Undo:
|
|
|
684
685
|
| Kind (`editInPlace({ kind })`) | Saves on | Esc |
|
|
685
686
|
|---|---|---|
|
|
686
687
|
| `text`: one line | Enter, or leaving it | puts the saved value back |
|
|
687
|
-
| `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line) | the same |
|
|
688
|
+
| `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line); with `autosave`, also a pause in typing | the same; with `autosave`, saves what's left and closes |
|
|
688
689
|
| `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
|
|
689
690
|
| `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
|
|
690
691
|
|
|
@@ -713,6 +714,53 @@ message (a 500, no answer). The control is `aria-invalid` and described
|
|
|
713
714
|
by the reason. Nothing reverts on its own, and there's no toast. Leaving
|
|
714
715
|
again retries; Esc gives up.
|
|
715
716
|
|
|
717
|
+
**Long text that saves itself (`autosave: true`)**, Notion-style, so
|
|
718
|
+
nobody has to click away to save notes. Opt in per field:
|
|
719
|
+
`editInPlace({ kind: 'long-text', autosave: true, ... })`.
|
|
720
|
+
|
|
721
|
+
- It saves about a second after typing stops, without leaving the field,
|
|
722
|
+
and at once on leaving it (a click or Tab elsewhere, Ctrl/Cmd+Enter,
|
|
723
|
+
opening another value, a link off the page).
|
|
724
|
+
- **Never read-only while it saves.** Typing carries on during a save;
|
|
725
|
+
the next save waits for the one in flight and sends whatever the text
|
|
726
|
+
is by then, so saves never cross and the latest always wins.
|
|
727
|
+
- Under the text, a quiet line in the muted caption tone
|
|
728
|
+
(`role="status"`): "Saving…" with a spinner while a save is in flight,
|
|
729
|
+
"Saved" with a check once nothing typed is waiting. No toast.
|
|
730
|
+
- **Refused:** exactly what was typed stays, with the reason under it, as
|
|
731
|
+
above. Typing again retries after the pause; a refused draft is never
|
|
732
|
+
re-sent on its own.
|
|
733
|
+
- **Esc saves what's left and closes.** Nothing reverts: what was saved a
|
|
734
|
+
second ago stays saved, so "give up" would only drop the last second.
|
|
735
|
+
- Rich text that can't save without rewriting what the person didn't edit
|
|
736
|
+
(`RichTextEditor`'s `onProblem`) saves nothing; its message shows after
|
|
737
|
+
the pause, and the next good edit saves.
|
|
738
|
+
- **Leaving the page** with a save still to come saves it first and goes
|
|
739
|
+
on; "Discard your changes?" shows only if that save is refused. Closing
|
|
740
|
+
the tab can't wait: it starts the save and the browser asks. Hiding the
|
|
741
|
+
page (another tab, a phone's home screen) saves too.
|
|
742
|
+
|
|
743
|
+
**Always editable (`alwaysEditable: true`, implies `autosave`)**: the
|
|
744
|
+
notes are the editor itself, shown at rest, with no pencil and no click
|
|
745
|
+
to open, and it never closes. Ctrl/Cmd+Enter or Esc saves now and leaves
|
|
746
|
+
the caret where it is. It sits beside the one open value rather than
|
|
747
|
+
being it: opening another value leaves it alone. Don't give its control
|
|
748
|
+
`autoFocus`. The cost: at rest the page shows the editor, not the read
|
|
749
|
+
view, so a link in the notes is edited there, not followed, and the
|
|
750
|
+
editor loads on every visit. Use it where the notes are what the page is
|
|
751
|
+
for; elsewhere `autosave` alone keeps the read view at rest.
|
|
752
|
+
|
|
753
|
+
```tsx
|
|
754
|
+
description: editInPlace({
|
|
755
|
+
kind: 'long-text',
|
|
756
|
+
autosave: true, // or alwaysEditable: true, with no autoFocus on the control
|
|
757
|
+
value: widget.description,
|
|
758
|
+
schema: widgetFormSchema.shape.description,
|
|
759
|
+
save: (description) => editField.mutateAsync({ description }),
|
|
760
|
+
control: (props) => <WidgetDescriptionEditor {...} />,
|
|
761
|
+
}),
|
|
762
|
+
```
|
|
763
|
+
|
|
716
764
|
**Leaving the page** (a link, Back) while a value holds unsaved text asks
|
|
717
765
|
"Discard your changes?" first (Keep editing / Discard); closing the tab
|
|
718
766
|
asks through the browser. A link press doesn't save the field on its
|
|
@@ -148,7 +148,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
148
148
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
149
149
|
// as a record's view.
|
|
150
150
|
const [store] = useState(() => new EditInPlaceStore());
|
|
151
|
-
const
|
|
151
|
+
const editing = useSyncExternalStore(store.subscribe, () => store.editing, () => store.editing);
|
|
152
152
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
153
153
|
// TanStack treats a column missing from this map as visible, so every
|
|
154
154
|
// column gets an entry once a list is given.
|
|
@@ -229,5 +229,5 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
229
229
|
}) }, 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 &&
|
|
230
230
|
index === row.getVisibleCells().length - 1 &&
|
|
231
231
|
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, {}) })] })] })] }))] }));
|
|
232
|
-
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [
|
|
232
|
+
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
|
|
233
233
|
}
|
|
@@ -21,6 +21,10 @@ export type EditFieldConfig = {
|
|
|
21
21
|
save: (value: unknown) => Promise<unknown>;
|
|
22
22
|
/** Whether the draft is still the saved value. Defaults to comparing as JSON. */
|
|
23
23
|
isEqual?: (a: unknown, b: unknown) => boolean;
|
|
24
|
+
/** Saves on its own a pause after each change, staying editable while it saves. */
|
|
25
|
+
autosave?: boolean;
|
|
26
|
+
/** Always open: the control is the value, with no click to open. Autosaves. */
|
|
27
|
+
alwaysEditable?: boolean;
|
|
24
28
|
};
|
|
25
29
|
export type EditFieldState = {
|
|
26
30
|
status: 'closed';
|
|
@@ -35,6 +39,19 @@ export type OpenFieldState = {
|
|
|
35
39
|
error: string | null;
|
|
36
40
|
/** A problem the control reports itself (rich text that can't save as it stands). */
|
|
37
41
|
problem: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* An autosaving field's save: in flight (the control stays editable),
|
|
44
|
+
* or done with nothing typed since. Absent otherwise.
|
|
45
|
+
*/
|
|
46
|
+
autosave?: 'saving' | 'saved';
|
|
47
|
+
/** Counts the times an always-editable field started over from a value saved elsewhere (`refresh`). */
|
|
48
|
+
revision?: number;
|
|
49
|
+
};
|
|
50
|
+
/** How long typing has to stop before an autosaving field saves. */
|
|
51
|
+
export declare const AUTOSAVE_DELAY_MS = 1000;
|
|
52
|
+
export type EditInPlaceStoreOptions = {
|
|
53
|
+
/** The pause before an autosave, in ms. Defaults to AUTOSAVE_DELAY_MS. */
|
|
54
|
+
autosaveDelay?: number;
|
|
38
55
|
};
|
|
39
56
|
/** Why a save failed, in words to show beside what was being saved. */
|
|
40
57
|
export declare function messageOf(error: unknown): string;
|
|
@@ -44,22 +61,50 @@ export declare class EditInPlaceStore {
|
|
|
44
61
|
private saving;
|
|
45
62
|
private openKey;
|
|
46
63
|
private listeners;
|
|
64
|
+
private readonly autosaveDelay;
|
|
65
|
+
private timers;
|
|
66
|
+
private autosaving;
|
|
67
|
+
private queued;
|
|
68
|
+
private always;
|
|
69
|
+
private holds;
|
|
70
|
+
constructor(options?: EditInPlaceStoreOptions);
|
|
47
71
|
subscribe: (listener: () => void) => () => void;
|
|
48
72
|
private emit;
|
|
49
73
|
private set;
|
|
50
74
|
/** A field joins the view. `config` is read fresh each time it's needed. */
|
|
51
75
|
register(key: string, config: () => EditFieldConfig): () => void;
|
|
52
76
|
state(key: string): EditFieldState;
|
|
53
|
-
/** The field that's open, if any. */
|
|
77
|
+
/** The field that's open, if any. An always-editable one never counts. */
|
|
54
78
|
get open(): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Whether anything is being edited: a field open, one that always is, or
|
|
81
|
+
* a move off the page waiting for its saves (`hold`).
|
|
82
|
+
*/
|
|
83
|
+
get editing(): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Keeps `editing` true until released: the leave prompt, saving on the
|
|
86
|
+
* way out, stays mounted (and its blocked move with it) even when that
|
|
87
|
+
* save closes the field.
|
|
88
|
+
*/
|
|
89
|
+
hold(): () => void;
|
|
55
90
|
/** Whether leaving the page now would lose something: a draft that isn't saved. */
|
|
56
91
|
get dirty(): boolean;
|
|
92
|
+
/** Whether everything unsaved saves on its own, so leaving can save it first rather than ask. */
|
|
93
|
+
get savesOnLeave(): boolean;
|
|
94
|
+
private unsaved;
|
|
95
|
+
private isEqual;
|
|
96
|
+
private autosaves;
|
|
57
97
|
/**
|
|
58
98
|
* Opens a field. The open one, if another, is saved first: if that
|
|
59
99
|
* fails it stays open and this one doesn't, and this returns false.
|
|
100
|
+
* An always-editable field is already open.
|
|
60
101
|
*/
|
|
61
102
|
start(key: string): Promise<boolean>;
|
|
62
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The control's value changed. Ignored while saving: the field is
|
|
105
|
+
* read-only then. An autosaving field takes it even mid-save, and saves
|
|
106
|
+
* once typing pauses.
|
|
107
|
+
*/
|
|
63
108
|
change(key: string, draft: unknown): void;
|
|
64
109
|
/** The control can't produce a value to save as it stands (`null` once it can). */
|
|
65
110
|
report(key: string, problem: string | null): void;
|
|
@@ -67,12 +112,38 @@ export declare class EditInPlaceStore {
|
|
|
67
112
|
* Saves the field (leaving it, Enter, a choice picked). Resolves true
|
|
68
113
|
* once it's closed (saved, or unchanged), false if it stays open (the
|
|
69
114
|
* schema refused it, or the save failed). `draft`, when given, is the
|
|
70
|
-
* value to save (a choice saves the moment it's picked).
|
|
115
|
+
* value to save (a choice saves the moment it's picked). An autosaving
|
|
116
|
+
* field saves what's left at once; an always-editable one stays open,
|
|
117
|
+
* and resolves true once what's on screen is saved.
|
|
71
118
|
*/
|
|
72
119
|
commit(key: string, draft?: unknown): Promise<boolean>;
|
|
73
|
-
|
|
120
|
+
private commitAutosaved;
|
|
121
|
+
private schedule;
|
|
122
|
+
private clearTimer;
|
|
123
|
+
/**
|
|
124
|
+
* Saves an autosaving field's latest draft now. With a save in flight,
|
|
125
|
+
* it waits behind it (one save queued, however many changes), then
|
|
126
|
+
* sends whatever the draft is by then. Resolves true once the draft it
|
|
127
|
+
* sent is saved (or there was nothing to send).
|
|
128
|
+
*/
|
|
129
|
+
private flush;
|
|
130
|
+
/**
|
|
131
|
+
* Saves every autosaving field that holds something unsaved, now
|
|
132
|
+
* (leaving the page). Resolves true once nothing unsaved is left.
|
|
133
|
+
*/
|
|
134
|
+
saveAll(): Promise<boolean>;
|
|
135
|
+
/**
|
|
136
|
+
* An always-editable field shows the saved value again when it changed
|
|
137
|
+
* elsewhere (another tab, the form), but only while nothing of its own
|
|
138
|
+
* is unsaved or saving. True when it did: the control starts over.
|
|
139
|
+
*/
|
|
140
|
+
refresh(key: string): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Esc: closes and shows the saved value again. Not while a save is in
|
|
143
|
+
* flight, and never for an always-editable field.
|
|
144
|
+
*/
|
|
74
145
|
cancel(key: string): void;
|
|
75
|
-
/** Leaving the page anyway: whatever is open is dropped. */
|
|
146
|
+
/** Leaving the page anyway: whatever is open is dropped, and nothing waiting to autosave is sent. */
|
|
76
147
|
discard(): void;
|
|
77
148
|
}
|
|
78
149
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { refusalReason } from '../../hooks/use-record-update.js';
|
|
2
|
+
/** How long typing has to stop before an autosaving field saves. */
|
|
3
|
+
export const AUTOSAVE_DELAY_MS = 1000;
|
|
2
4
|
const CLOSED = { status: 'closed' };
|
|
3
5
|
const sameAsJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
4
6
|
/** Why a save failed, in words to show beside what was being saved. */
|
|
@@ -15,6 +17,18 @@ export class EditInPlaceStore {
|
|
|
15
17
|
saving = new Map();
|
|
16
18
|
openKey = null;
|
|
17
19
|
listeners = new Set();
|
|
20
|
+
// Autosaving fields: the pause before each save, the save in flight,
|
|
21
|
+
// the one waiting behind it, and the fields that are always open.
|
|
22
|
+
autosaveDelay;
|
|
23
|
+
timers = new Map();
|
|
24
|
+
autosaving = new Map();
|
|
25
|
+
queued = new Map();
|
|
26
|
+
always = new Set();
|
|
27
|
+
// Leaving the page, waiting for its saves: the leave prompt stays.
|
|
28
|
+
holds = 0;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.autosaveDelay = options.autosaveDelay ?? AUTOSAVE_DELAY_MS;
|
|
31
|
+
}
|
|
18
32
|
subscribe = (listener) => {
|
|
19
33
|
this.listeners.add(listener);
|
|
20
34
|
return () => {
|
|
@@ -34,38 +48,89 @@ export class EditInPlaceStore {
|
|
|
34
48
|
/** A field joins the view. `config` is read fresh each time it's needed. */
|
|
35
49
|
register(key, config) {
|
|
36
50
|
this.configs.set(key, config);
|
|
51
|
+
const { alwaysEditable, value } = config();
|
|
52
|
+
if (alwaysEditable) {
|
|
53
|
+
this.always.add(key);
|
|
54
|
+
this.set(key, { status: 'open', draft: value, initial: value, error: null, problem: null });
|
|
55
|
+
}
|
|
37
56
|
return () => {
|
|
57
|
+
// A save still waiting for the pause goes now: the field is leaving,
|
|
58
|
+
// not giving up what was typed.
|
|
59
|
+
if (this.timers.has(key))
|
|
60
|
+
void this.flush(key);
|
|
38
61
|
this.configs.delete(key);
|
|
39
62
|
this.states.delete(key);
|
|
40
63
|
if (this.openKey === key)
|
|
41
64
|
this.openKey = null;
|
|
65
|
+
if (this.always.delete(key))
|
|
66
|
+
this.emit();
|
|
42
67
|
};
|
|
43
68
|
}
|
|
44
69
|
state(key) {
|
|
45
70
|
return this.states.get(key) ?? CLOSED;
|
|
46
71
|
}
|
|
47
|
-
/** The field that's open, if any. */
|
|
72
|
+
/** The field that's open, if any. An always-editable one never counts. */
|
|
48
73
|
get open() {
|
|
49
74
|
return this.openKey;
|
|
50
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether anything is being edited: a field open, one that always is, or
|
|
78
|
+
* a move off the page waiting for its saves (`hold`).
|
|
79
|
+
*/
|
|
80
|
+
get editing() {
|
|
81
|
+
return this.openKey !== null || this.always.size > 0 || this.holds > 0;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Keeps `editing` true until released: the leave prompt, saving on the
|
|
85
|
+
* way out, stays mounted (and its blocked move with it) even when that
|
|
86
|
+
* save closes the field.
|
|
87
|
+
*/
|
|
88
|
+
hold() {
|
|
89
|
+
this.holds += 1;
|
|
90
|
+
let released = false;
|
|
91
|
+
return () => {
|
|
92
|
+
if (released)
|
|
93
|
+
return;
|
|
94
|
+
released = true;
|
|
95
|
+
this.holds -= 1;
|
|
96
|
+
this.emit();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
51
99
|
/** Whether leaving the page now would lose something: a draft that isn't saved. */
|
|
52
100
|
get dirty() {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
101
|
+
return this.unsaved().length > 0;
|
|
102
|
+
}
|
|
103
|
+
/** Whether everything unsaved saves on its own, so leaving can save it first rather than ask. */
|
|
104
|
+
get savesOnLeave() {
|
|
105
|
+
return this.unsaved().every((key) => this.autosaves(key));
|
|
106
|
+
}
|
|
107
|
+
unsaved() {
|
|
108
|
+
const keys = new Set(this.always);
|
|
109
|
+
if (this.openKey !== null)
|
|
110
|
+
keys.add(this.openKey);
|
|
111
|
+
return [...keys].filter((key) => {
|
|
112
|
+
const state = this.state(key);
|
|
113
|
+
if (state.status === 'closed')
|
|
114
|
+
return false;
|
|
115
|
+
if (state.status === 'saving' || this.autosaving.has(key) || state.error !== null)
|
|
116
|
+
return true;
|
|
117
|
+
return !this.isEqual(key)(state.draft, state.initial);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
isEqual(key) {
|
|
121
|
+
return this.configs.get(key)?.().isEqual ?? sameAsJson;
|
|
122
|
+
}
|
|
123
|
+
autosaves(key) {
|
|
124
|
+
const config = this.configs.get(key)?.();
|
|
125
|
+
return Boolean(config?.autosave || config?.alwaysEditable);
|
|
62
126
|
}
|
|
63
127
|
/**
|
|
64
128
|
* Opens a field. The open one, if another, is saved first: if that
|
|
65
129
|
* fails it stays open and this one doesn't, and this returns false.
|
|
130
|
+
* An always-editable field is already open.
|
|
66
131
|
*/
|
|
67
132
|
async start(key) {
|
|
68
|
-
if (this.openKey === key)
|
|
133
|
+
if (this.openKey === key || this.always.has(key))
|
|
69
134
|
return true;
|
|
70
135
|
if (this.openKey !== null) {
|
|
71
136
|
const closed = await this.commit(this.openKey);
|
|
@@ -82,12 +147,21 @@ export class EditInPlaceStore {
|
|
|
82
147
|
this.set(key, { status: 'open', draft: config.value, initial: config.value, error: null, problem: null });
|
|
83
148
|
return true;
|
|
84
149
|
}
|
|
85
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* The control's value changed. Ignored while saving: the field is
|
|
152
|
+
* read-only then. An autosaving field takes it even mid-save, and saves
|
|
153
|
+
* once typing pauses.
|
|
154
|
+
*/
|
|
86
155
|
change(key, draft) {
|
|
87
156
|
const state = this.state(key);
|
|
88
157
|
if (state.status !== 'open')
|
|
89
158
|
return;
|
|
90
|
-
this.
|
|
159
|
+
if (!this.autosaves(key)) {
|
|
160
|
+
this.set(key, { ...state, draft });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.set(key, { ...state, draft, autosave: this.autosaving.has(key) ? 'saving' : undefined });
|
|
164
|
+
this.schedule(key);
|
|
91
165
|
}
|
|
92
166
|
/** The control can't produce a value to save as it stands (`null` once it can). */
|
|
93
167
|
report(key, problem) {
|
|
@@ -95,14 +169,22 @@ export class EditInPlaceStore {
|
|
|
95
169
|
if (state.status === 'closed' || state.problem === problem)
|
|
96
170
|
return;
|
|
97
171
|
this.set(key, { ...state, problem });
|
|
172
|
+
// Autosaving: after the pause, the problem shows (nothing is saved),
|
|
173
|
+
// or, once it's gone, the latest draft saves.
|
|
174
|
+
if (state.status === 'open' && this.autosaves(key))
|
|
175
|
+
this.schedule(key);
|
|
98
176
|
}
|
|
99
177
|
/**
|
|
100
178
|
* Saves the field (leaving it, Enter, a choice picked). Resolves true
|
|
101
179
|
* once it's closed (saved, or unchanged), false if it stays open (the
|
|
102
180
|
* schema refused it, or the save failed). `draft`, when given, is the
|
|
103
|
-
* value to save (a choice saves the moment it's picked).
|
|
181
|
+
* value to save (a choice saves the moment it's picked). An autosaving
|
|
182
|
+
* field saves what's left at once; an always-editable one stays open,
|
|
183
|
+
* and resolves true once what's on screen is saved.
|
|
104
184
|
*/
|
|
105
185
|
commit(key, draft) {
|
|
186
|
+
if (this.autosaves(key))
|
|
187
|
+
return this.commitAutosaved(key, draft);
|
|
106
188
|
const inFlight = this.saving.get(key);
|
|
107
189
|
if (inFlight)
|
|
108
190
|
return inFlight;
|
|
@@ -145,16 +227,152 @@ export class EditInPlaceStore {
|
|
|
145
227
|
this.saving.set(key, save);
|
|
146
228
|
return save;
|
|
147
229
|
}
|
|
148
|
-
|
|
149
|
-
|
|
230
|
+
async commitAutosaved(key, draft) {
|
|
231
|
+
if (this.state(key).status === 'closed')
|
|
232
|
+
return true;
|
|
233
|
+
if (draft !== undefined)
|
|
234
|
+
this.change(key, draft);
|
|
235
|
+
const saved = await this.flush(key);
|
|
236
|
+
const now = this.state(key);
|
|
237
|
+
if (now.status === 'closed')
|
|
238
|
+
return true;
|
|
239
|
+
// Typed again while that save was in flight: its own save follows.
|
|
240
|
+
if (!saved || !this.isEqual(key)(now.draft, now.initial))
|
|
241
|
+
return false;
|
|
242
|
+
if (!this.always.has(key))
|
|
243
|
+
this.set(key, CLOSED);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
schedule(key) {
|
|
247
|
+
this.clearTimer(key);
|
|
248
|
+
this.timers.set(key, setTimeout(() => {
|
|
249
|
+
this.timers.delete(key);
|
|
250
|
+
void this.flush(key);
|
|
251
|
+
}, this.autosaveDelay));
|
|
252
|
+
}
|
|
253
|
+
clearTimer(key) {
|
|
254
|
+
clearTimeout(this.timers.get(key));
|
|
255
|
+
this.timers.delete(key);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Saves an autosaving field's latest draft now. With a save in flight,
|
|
259
|
+
* it waits behind it (one save queued, however many changes), then
|
|
260
|
+
* sends whatever the draft is by then. Resolves true once the draft it
|
|
261
|
+
* sent is saved (or there was nothing to send).
|
|
262
|
+
*/
|
|
263
|
+
flush(key) {
|
|
264
|
+
this.clearTimer(key);
|
|
265
|
+
const inFlight = this.autosaving.get(key);
|
|
266
|
+
if (inFlight) {
|
|
267
|
+
let next = this.queued.get(key);
|
|
268
|
+
if (!next) {
|
|
269
|
+
next = inFlight.promise.then((saved) => {
|
|
270
|
+
this.queued.delete(key);
|
|
271
|
+
// A failed save isn't sent again as it was: only something new is.
|
|
272
|
+
const now = this.state(key);
|
|
273
|
+
if (!saved && now.status !== 'closed' && this.isEqual(key)(now.draft, inFlight.draft))
|
|
274
|
+
return false;
|
|
275
|
+
return this.flush(key);
|
|
276
|
+
});
|
|
277
|
+
this.queued.set(key, next);
|
|
278
|
+
}
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
150
281
|
const state = this.state(key);
|
|
151
282
|
if (state.status !== 'open')
|
|
283
|
+
return Promise.resolve(state.status === 'closed');
|
|
284
|
+
const config = this.configs.get(key)?.();
|
|
285
|
+
if (!config)
|
|
286
|
+
return Promise.resolve(false);
|
|
287
|
+
if (state.problem) {
|
|
288
|
+
this.set(key, { ...state, error: state.problem });
|
|
289
|
+
return Promise.resolve(false);
|
|
290
|
+
}
|
|
291
|
+
const isEqual = config.isEqual ?? sameAsJson;
|
|
292
|
+
if (isEqual(state.draft, state.initial)) {
|
|
293
|
+
if (state.error !== null)
|
|
294
|
+
this.set(key, { ...state, error: null });
|
|
295
|
+
return Promise.resolve(true);
|
|
296
|
+
}
|
|
297
|
+
const parsed = config.schema ? config.schema.safeParse(state.draft) : { success: true, data: state.draft };
|
|
298
|
+
if (!parsed.success) {
|
|
299
|
+
this.set(key, { ...state, error: parsed.error.issues[0]?.message ?? 'Check this value.' });
|
|
300
|
+
return Promise.resolve(false);
|
|
301
|
+
}
|
|
302
|
+
const sent = state.draft;
|
|
303
|
+
this.set(key, { ...state, error: null, autosave: 'saving' });
|
|
304
|
+
const promise = config
|
|
305
|
+
.save(parsed.data)
|
|
306
|
+
.then(() => {
|
|
307
|
+
const now = this.state(key);
|
|
308
|
+
if (now.status === 'open') {
|
|
309
|
+
const status = isEqual(now.draft, sent) ? 'saved' : this.queued.has(key) ? 'saving' : undefined;
|
|
310
|
+
this.set(key, { ...now, initial: sent, error: null, autosave: status });
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}, (error) => {
|
|
314
|
+
const now = this.state(key);
|
|
315
|
+
if (now.status === 'open')
|
|
316
|
+
this.set(key, { ...now, error: messageOf(error), autosave: undefined });
|
|
317
|
+
return false;
|
|
318
|
+
})
|
|
319
|
+
.finally(() => this.autosaving.delete(key));
|
|
320
|
+
this.autosaving.set(key, { promise, draft: sent });
|
|
321
|
+
return promise;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Saves every autosaving field that holds something unsaved, now
|
|
325
|
+
* (leaving the page). Resolves true once nothing unsaved is left.
|
|
326
|
+
*/
|
|
327
|
+
async saveAll() {
|
|
328
|
+
const keys = this.unsaved().filter((key) => this.autosaves(key));
|
|
329
|
+
const results = await Promise.all(keys.map((key) => this.flush(key)));
|
|
330
|
+
return results.every(Boolean) && !this.dirty;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* An always-editable field shows the saved value again when it changed
|
|
334
|
+
* elsewhere (another tab, the form), but only while nothing of its own
|
|
335
|
+
* is unsaved or saving. True when it did: the control starts over.
|
|
336
|
+
*/
|
|
337
|
+
refresh(key) {
|
|
338
|
+
const state = this.state(key);
|
|
339
|
+
const config = this.configs.get(key)?.();
|
|
340
|
+
if (!this.always.has(key) || state.status !== 'open' || !config)
|
|
341
|
+
return false;
|
|
342
|
+
if (this.timers.has(key) || this.autosaving.has(key) || state.error !== null)
|
|
343
|
+
return false;
|
|
344
|
+
const isEqual = config.isEqual ?? sameAsJson;
|
|
345
|
+
if (!isEqual(state.draft, state.initial) || isEqual(config.value, state.initial))
|
|
346
|
+
return false;
|
|
347
|
+
const revision = (state.revision ?? 0) + 1;
|
|
348
|
+
this.set(key, { status: 'open', draft: config.value, initial: config.value, error: null, problem: null, revision });
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Esc: closes and shows the saved value again. Not while a save is in
|
|
353
|
+
* flight, and never for an always-editable field.
|
|
354
|
+
*/
|
|
355
|
+
cancel(key) {
|
|
356
|
+
const state = this.state(key);
|
|
357
|
+
if (state.status !== 'open' || this.always.has(key) || this.autosaving.has(key))
|
|
152
358
|
return;
|
|
359
|
+
this.clearTimer(key);
|
|
153
360
|
this.set(key, CLOSED);
|
|
154
361
|
}
|
|
155
|
-
/** Leaving the page anyway: whatever is open is dropped. */
|
|
362
|
+
/** Leaving the page anyway: whatever is open is dropped, and nothing waiting to autosave is sent. */
|
|
156
363
|
discard() {
|
|
157
|
-
|
|
158
|
-
this.
|
|
364
|
+
for (const key of [...this.timers.keys()])
|
|
365
|
+
this.clearTimer(key);
|
|
366
|
+
if (this.openKey !== null) {
|
|
367
|
+
if (this.autosaving.has(this.openKey))
|
|
368
|
+
this.set(this.openKey, CLOSED);
|
|
369
|
+
else
|
|
370
|
+
this.cancel(this.openKey);
|
|
371
|
+
}
|
|
372
|
+
for (const key of this.always) {
|
|
373
|
+
const state = this.state(key);
|
|
374
|
+
if (state.status === 'open')
|
|
375
|
+
this.set(key, { ...state, draft: state.initial, error: null, autosave: undefined });
|
|
376
|
+
}
|
|
159
377
|
}
|
|
160
378
|
}
|
|
@@ -58,6 +58,8 @@ export type EditInPlace = {
|
|
|
58
58
|
};
|
|
59
59
|
save: (value: unknown) => Promise<unknown>;
|
|
60
60
|
isEqual?: (a: unknown, b: unknown) => boolean;
|
|
61
|
+
autosave?: boolean;
|
|
62
|
+
alwaysEditable?: boolean;
|
|
61
63
|
};
|
|
62
64
|
export type EditInPlaceOptions<TDraft, TValue> = {
|
|
63
65
|
kind: EditKind;
|
|
@@ -82,6 +84,22 @@ export type EditInPlaceOptions<TDraft, TValue> = {
|
|
|
82
84
|
save: (value: TValue) => Promise<unknown>;
|
|
83
85
|
/** Whether a draft is still the saved value. Defaults to comparing as JSON. */
|
|
84
86
|
isEqual?: (a: TDraft, b: TDraft) => boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Saves on its own, Notion-style, for long text: about a second after
|
|
89
|
+
* typing stops, and at once on leaving the field or the page. The
|
|
90
|
+
* control stays editable while it saves ("Saving…", then "Saved"), and
|
|
91
|
+
* saves never cross: the next waits for the one in flight and sends the
|
|
92
|
+
* latest draft. Esc saves and closes; nothing reverts. A refusal keeps
|
|
93
|
+
* the draft and its reason, as without it.
|
|
94
|
+
*/
|
|
95
|
+
autosave?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The control is the value: shown at rest, with no click to open (the
|
|
98
|
+
* notes are the editor). Implies `autosave`. Its control shouldn't take
|
|
99
|
+
* `autoFocus`. At rest it shows the control, not the read view: a link
|
|
100
|
+
* in rich text is edited there, not followed.
|
|
101
|
+
*/
|
|
102
|
+
alwaysEditable?: boolean;
|
|
85
103
|
};
|
|
86
104
|
/** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
|
|
87
105
|
export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
@@ -28,7 +28,12 @@ export declare function EditableField({ label, edit, children, layout, className
|
|
|
28
28
|
* While a field holds something unsaved, leaving asks first: an in-app
|
|
29
29
|
* link or Back (React Router's blocker, so the app needs a data router),
|
|
30
30
|
* or closing the tab (`beforeunload`). Rendered by EntityView only while
|
|
31
|
-
* a field is
|
|
31
|
+
* a field is being edited, so a view nobody's editing never blocks anything.
|
|
32
|
+
*
|
|
33
|
+
* When everything unsaved autosaves, an in-app move saves it first and
|
|
34
|
+
* goes on once saved, asking only if that save fails. Closing the tab
|
|
35
|
+
* can't wait, so it starts the save and asks; hiding the page (another
|
|
36
|
+
* tab, a phone's home screen) saves too.
|
|
32
37
|
*/
|
|
33
38
|
export declare function LeaveGuard({ store }: {
|
|
34
39
|
store: EditInPlaceStore;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// A value on a record's view that turns into its form control where it's
|
|
3
3
|
// shown, and saves when you leave it (cell-patterns.md "Editable value").
|
|
4
4
|
// EntityView wraps every field, section and title marked `edit`; a header
|
|
@@ -11,8 +11,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
// back. Unchanged closes with no request.
|
|
12
12
|
// - Saving: the control stays, read-only, with "Saving…" under it, until
|
|
13
13
|
// the server agrees. Failed: it stays, with the draft and the reason.
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// - `autosave`: it saves itself a pause after typing stops and stays
|
|
15
|
+
// editable while it does ("Saving…", then "Saved" under it). Leaving,
|
|
16
|
+
// Ctrl/Cmd+Enter and Esc save what's left now and close it.
|
|
17
|
+
// - `alwaysEditable`: the control is shown at rest, with no pencil and no
|
|
18
|
+
// click to open, and never closes. Ctrl/Cmd+Enter and Esc save now.
|
|
19
|
+
import { CheckIcon, PencilIcon } from 'lucide-react';
|
|
20
|
+
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
16
21
|
import { useBlocker } from 'react-router';
|
|
17
22
|
import { cn } from 'cn';
|
|
18
23
|
import { Button } from '../ui/button.js';
|
|
@@ -20,6 +25,7 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
|
|
|
20
25
|
import { FieldError } from '../ui/field.js';
|
|
21
26
|
import { Spinner } from '../ui/spinner.js';
|
|
22
27
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
28
|
+
import { escapePassedThrough } from './escape-passthrough.js';
|
|
23
29
|
// What a click on the shown value must leave alone: links navigate,
|
|
24
30
|
// buttons and boxes do their own thing (a list item's done box ticks it).
|
|
25
31
|
const INTERACTIVE = 'a[href], button, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="switch"]';
|
|
@@ -66,10 +72,24 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
66
72
|
// Set by a keyboard save or Esc: the caret goes back to the edit button.
|
|
67
73
|
const refocus = useRef(false);
|
|
68
74
|
const blurTimer = useRef(undefined);
|
|
75
|
+
const always = Boolean(edit.alwaysEditable);
|
|
76
|
+
const autosave = always || Boolean(edit.autosave);
|
|
77
|
+
// An always-editable control starts over (it's uncontrolled: a rich-text
|
|
78
|
+
// editor reads its value once) when the value changed elsewhere while
|
|
79
|
+
// nothing of its own was unsaved and the caret wasn't in it.
|
|
80
|
+
const refresh = useCallback(() => {
|
|
81
|
+
if (!container.current?.contains(document.activeElement))
|
|
82
|
+
store.refresh(key);
|
|
83
|
+
}, [store, key]);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (always)
|
|
86
|
+
refresh();
|
|
87
|
+
}, [always, refresh, edit.value]);
|
|
69
88
|
const isOpen = state.status !== 'closed';
|
|
70
89
|
const wasOpen = useRef(false);
|
|
71
90
|
useEffect(() => {
|
|
72
|
-
|
|
91
|
+
// Always open: the caret stays wherever the person put it.
|
|
92
|
+
if (isOpen && !wasOpen.current && !always) {
|
|
73
93
|
for (const selector of FOCUS_ORDER) {
|
|
74
94
|
const target = container.current?.querySelector(selector);
|
|
75
95
|
if (target) {
|
|
@@ -86,7 +106,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
86
106
|
(editButton.current ?? triggerBox.current?.querySelector('button'))?.focus();
|
|
87
107
|
}
|
|
88
108
|
wasOpen.current = isOpen;
|
|
89
|
-
}, [isOpen]);
|
|
109
|
+
}, [isOpen, always]);
|
|
90
110
|
useEffect(() => () => window.clearTimeout(blurTimer.current), []);
|
|
91
111
|
// Whether an Esc is closing a list inside the control. Read on the way
|
|
92
112
|
// down (window, capture): a picker closes its list from a document
|
|
@@ -127,20 +147,35 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
127
147
|
return;
|
|
128
148
|
start();
|
|
129
149
|
};
|
|
150
|
+
// Always editable: Ctrl/Cmd+Enter or Esc saves now, the caret staying put.
|
|
151
|
+
const saveNow = () => {
|
|
152
|
+
window.clearTimeout(blurTimer.current);
|
|
153
|
+
void store.commit(key);
|
|
154
|
+
};
|
|
130
155
|
const onKeyDown = (event) => {
|
|
131
156
|
if (state.status !== 'open')
|
|
132
157
|
return;
|
|
133
158
|
if (event.key === 'Escape') {
|
|
134
|
-
// A list open inside the control closes first; Esc again gives up
|
|
135
|
-
|
|
159
|
+
// A list open inside the control closes first; Esc again gives up
|
|
160
|
+
// (or, autosaving, saves what's left and closes: nothing reverts).
|
|
161
|
+
if ((event.defaultPrevented && !escapePassedThrough(event.nativeEvent)) || escapeClosesList.current)
|
|
136
162
|
return;
|
|
137
163
|
event.preventDefault();
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
if (always)
|
|
165
|
+
saveNow();
|
|
166
|
+
else if (autosave)
|
|
167
|
+
commitFromKeyboard();
|
|
168
|
+
else {
|
|
169
|
+
refocus.current = true;
|
|
170
|
+
cancel();
|
|
171
|
+
}
|
|
140
172
|
}
|
|
141
173
|
else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
142
174
|
event.preventDefault();
|
|
143
|
-
|
|
175
|
+
if (always)
|
|
176
|
+
saveNow();
|
|
177
|
+
else
|
|
178
|
+
commitFromKeyboard();
|
|
144
179
|
}
|
|
145
180
|
else if (event.key === 'Enter' && edit.kind === 'text' && !event.shiftKey && !event.altKey) {
|
|
146
181
|
event.preventDefault();
|
|
@@ -149,47 +184,102 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
149
184
|
};
|
|
150
185
|
// Leaving the field saves it. Focus moving into the control's own list
|
|
151
186
|
// (a portal) still counts as inside: React sends its focus events here.
|
|
187
|
+
// An autosaving field saves on its way to a link too: leaving the page
|
|
188
|
+
// then waits for that save rather than asking.
|
|
152
189
|
const onBlur = (event) => {
|
|
153
190
|
window.clearTimeout(blurTimer.current);
|
|
154
|
-
if (event.relatedTarget?.closest(LEAVING))
|
|
191
|
+
if (!autosave && event.relatedTarget?.closest(LEAVING))
|
|
155
192
|
return;
|
|
156
193
|
blurTimer.current = window.setTimeout(() => {
|
|
157
|
-
if (store.state(key).status
|
|
158
|
-
|
|
194
|
+
if (store.state(key).status !== 'open')
|
|
195
|
+
return;
|
|
196
|
+
void store.commit(key).then(() => {
|
|
197
|
+
if (always)
|
|
198
|
+
refresh();
|
|
199
|
+
});
|
|
159
200
|
}, 0);
|
|
160
201
|
};
|
|
161
202
|
const onFocus = () => window.clearTimeout(blurTimer.current);
|
|
162
|
-
if (state.status === 'closed') {
|
|
203
|
+
if (state.status === 'closed' && !always) {
|
|
163
204
|
if (trigger)
|
|
164
205
|
return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
|
|
165
206
|
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, {}) })] }));
|
|
166
207
|
}
|
|
167
|
-
|
|
168
|
-
|
|
208
|
+
// Always editable, before the store has it (the first render): the saved value.
|
|
209
|
+
const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
|
|
210
|
+
const saving = open.status === 'saving';
|
|
211
|
+
const autosaving = open.autosave === 'saving';
|
|
212
|
+
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
|
|
213
|
+
!open.error && (_jsxs(_Fragment, { children: [_jsx(CheckIcon, { "aria-hidden": "true", className: "size-3.5" }), "Saved"] }))) })] })) : 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"] })) : (open.error && _jsx(FieldError, { className: "type-caption", children: open.error })) })] }));
|
|
169
214
|
}
|
|
170
215
|
/**
|
|
171
216
|
* While a field holds something unsaved, leaving asks first: an in-app
|
|
172
217
|
* link or Back (React Router's blocker, so the app needs a data router),
|
|
173
218
|
* or closing the tab (`beforeunload`). Rendered by EntityView only while
|
|
174
|
-
* a field is
|
|
219
|
+
* a field is being edited, so a view nobody's editing never blocks anything.
|
|
220
|
+
*
|
|
221
|
+
* When everything unsaved autosaves, an in-app move saves it first and
|
|
222
|
+
* goes on once saved, asking only if that save fails. Closing the tab
|
|
223
|
+
* can't wait, so it starts the save and asks; hiding the page (another
|
|
224
|
+
* tab, a phone's home screen) saves too.
|
|
175
225
|
*/
|
|
176
226
|
export function LeaveGuard({ store }) {
|
|
177
227
|
const dirty = useSyncExternalStore(store.subscribe, () => store.dirty, () => store.dirty);
|
|
228
|
+
const savesOnLeave = useSyncExternalStore(store.subscribe, () => store.savesOnLeave, () => store.savesOnLeave);
|
|
178
229
|
const blocker = useBlocker(({ currentLocation, nextLocation }) => store.dirty && currentLocation.pathname !== nextLocation.pathname);
|
|
230
|
+
const [saveFailed, setSaveFailed] = useState(false);
|
|
231
|
+
const latestBlocker = useRef(blocker);
|
|
232
|
+
useLayoutEffect(() => {
|
|
233
|
+
latestBlocker.current = blocker;
|
|
234
|
+
});
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (blocker.state !== 'blocked' || !store.savesOnLeave)
|
|
237
|
+
return;
|
|
238
|
+
let live = true;
|
|
239
|
+
// The save may close the field; this prompt (and the move it blocked)
|
|
240
|
+
// stays until the save is done.
|
|
241
|
+
const release = store.hold();
|
|
242
|
+
void store.saveAll().then((saved) => {
|
|
243
|
+
if (live) {
|
|
244
|
+
const current = latestBlocker.current;
|
|
245
|
+
if (saved && current.state === 'blocked')
|
|
246
|
+
current.proceed();
|
|
247
|
+
else if (!saved)
|
|
248
|
+
setSaveFailed(true);
|
|
249
|
+
}
|
|
250
|
+
release();
|
|
251
|
+
});
|
|
252
|
+
return () => {
|
|
253
|
+
live = false;
|
|
254
|
+
};
|
|
255
|
+
}, [blocker.state, store]);
|
|
179
256
|
useEffect(() => {
|
|
180
257
|
if (!dirty)
|
|
181
258
|
return;
|
|
182
259
|
const onBeforeUnload = (event) => {
|
|
260
|
+
void store.saveAll();
|
|
183
261
|
event.preventDefault();
|
|
184
262
|
};
|
|
263
|
+
const onVisibilityChange = () => {
|
|
264
|
+
if (document.visibilityState === 'hidden')
|
|
265
|
+
void store.saveAll();
|
|
266
|
+
};
|
|
185
267
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
268
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
269
|
+
return () => {
|
|
270
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
271
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
272
|
+
};
|
|
273
|
+
}, [dirty, store]);
|
|
274
|
+
return (_jsx(Dialog, { open: blocker.state === 'blocked' && (!savesOnLeave || saveFailed), onOpenChange: (open) => {
|
|
275
|
+
if (open)
|
|
276
|
+
return;
|
|
277
|
+
setSaveFailed(false);
|
|
278
|
+
if (blocker.state === 'blocked')
|
|
190
279
|
blocker.reset();
|
|
191
280
|
}, children: _jsxs(DialogContent, { showCloseButton: false, "data-leave-guard": "", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Discard your changes?" }), _jsx(DialogDescription, { children: "A field you're editing hasn't been saved. Leaving now loses what you typed." })] }), _jsxs(DialogFooter, { children: [_jsx(DialogClose, { render: _jsx(Button, { variant: "outline" }), children: "Keep editing" }), _jsx(Button, { variant: "destructive", onClick: () => {
|
|
192
281
|
store.discard();
|
|
282
|
+
setSaveFailed(false);
|
|
193
283
|
if (blocker.state === 'blocked')
|
|
194
284
|
blocker.proceed();
|
|
195
285
|
}, children: "Discard" })] })] }) }));
|
|
@@ -25,7 +25,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
25
25
|
// header badge uses it directly. A field not marked renders exactly as
|
|
26
26
|
// before. Edit, in `actions`, stays for everything else. A sub-item list
|
|
27
27
|
// edits in place too (editInPlace's `list` kind, editable-list.tsx): its
|
|
28
|
-
// items added, edited, moved and removed on the page.
|
|
28
|
+
// items added, edited, moved and removed on the page. Long text can
|
|
29
|
+
// autosave as it's typed (`autosave`), and be the editor itself, with no
|
|
30
|
+
// click to open (`alwaysEditable`).
|
|
29
31
|
//
|
|
30
32
|
// Two layouts. `column` (the default): every section in one column capped
|
|
31
33
|
// at max-w-4xl. `rail`, for a record with long content: the page takes the
|
|
@@ -136,9 +138,9 @@ function Rail({ label, sections }) {
|
|
|
136
138
|
}
|
|
137
139
|
export function EntityView({ title, titleEdit, badges, actions, sections, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
|
|
138
140
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
139
|
-
// while a field is open.
|
|
141
|
+
// while a field is open (or one is always editable).
|
|
140
142
|
const [store] = useState(() => new EditInPlaceStore());
|
|
141
|
-
const
|
|
143
|
+
const editing = useSyncExternalStore(store.subscribe, () => store.editing, () => store.editing);
|
|
142
144
|
if (error?.kind === 'notfound') {
|
|
143
145
|
// A record that doesn't exist and one that belongs to another user are
|
|
144
146
|
// the same plain 404, so this never says which. Retrying can't help.
|
|
@@ -150,5 +152,5 @@ export function EntityView({ title, titleEdit, badges, actions, sections, layout
|
|
|
150
152
|
return _jsx(EntityViewSkeleton, { layout: layout });
|
|
151
153
|
const railSections = sections.filter((section) => section.placement === 'rail');
|
|
152
154
|
const mainSections = sections.filter((section) => section.placement !== 'rail');
|
|
153
|
-
return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [
|
|
155
|
+
return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title)))] }) }));
|
|
154
156
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// An Esc the rich-text editor didn't use. ProseMirror marks every Esc
|
|
2
|
+
// handled (its own keydown calls preventDefault, to stop the browser's
|
|
3
|
+
// default), so a field around the editor can't tell from
|
|
4
|
+
// `defaultPrevented` whether a menu over the text took it. The editor
|
|
5
|
+
// records the ones none of its menus did, and editing in place acts on
|
|
6
|
+
// those (cell-patterns.md "Editable value": Esc gives up, or saves).
|
|
7
|
+
const passedThrough = new WeakSet();
|
|
8
|
+
export function passEscapeThrough(event) {
|
|
9
|
+
passedThrough.add(event);
|
|
10
|
+
}
|
|
11
|
+
export function escapePassedThrough(event) {
|
|
12
|
+
return passedThrough.has(event);
|
|
13
|
+
}
|
|
@@ -55,6 +55,7 @@ import { Field, FieldError, FieldLabel } from '../ui/field.js';
|
|
|
55
55
|
import { Input } from '../ui/input.js';
|
|
56
56
|
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
57
57
|
import { linkHref } from '../../lib/link-href.js';
|
|
58
|
+
import { passEscapeThrough } from './escape-passthrough.js';
|
|
58
59
|
import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
|
|
59
60
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
60
61
|
import { headingClass } from './prose-look.js';
|
|
@@ -477,6 +478,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
477
478
|
setDismissed({ from, to });
|
|
478
479
|
return true;
|
|
479
480
|
}
|
|
481
|
+
// An Esc nothing over the text took: the field's to act on,
|
|
482
|
+
// though ProseMirror will mark it handled.
|
|
483
|
+
if (event.key === 'Escape')
|
|
484
|
+
passEscapeThrough(event);
|
|
480
485
|
const mod = event.metaKey || event.ctrlKey;
|
|
481
486
|
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
482
487
|
event.preventDefault();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.1",
|
|
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": {
|