@tristan2828/ui-foundation 4.3.1 → 4.4.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.
@@ -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 Categories, with
660
- the controls in `widget-fields.tsx`, the same ones the form uses.
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 openField = useSyncExternalStore(store.subscribe, () => store.open, () => store.open);
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: [openField !== null && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
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
- /** The control's value changed. Ignored while saving: the field is read-only then. */
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
- /** Esc: closes and shows the saved value again. Not while a save is in flight. */
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
- if (this.openKey === null)
54
- return false;
55
- const state = this.state(this.openKey);
56
- if (state.status === 'closed')
57
- return false;
58
- if (state.status === 'saving' || state.error !== null)
59
- return true;
60
- const isEqual = this.configs.get(this.openKey)?.().isEqual ?? sameAsJson;
61
- return !isEqual(state.draft, state.initial);
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
- /** The control's value changed. Ignored while saving: the field is read-only then. */
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.set(key, { ...state, draft });
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
- /** Esc: closes and shows the saved value again. Not while a save is in flight. */
149
- cancel(key) {
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
- if (this.openKey !== null)
158
- this.cancel(this.openKey);
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 open, so a view nobody's editing never blocks anything.
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
- import { PencilIcon } from 'lucide-react';
15
- import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
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
- if (isOpen && !wasOpen.current) {
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
- if (event.defaultPrevented || escapeClosesList.current)
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
- refocus.current = true;
139
- cancel();
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
- commitFromKeyboard();
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 === 'open')
158
- void store.commit(key);
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
- const saving = state.status === 'saving';
168
- return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving ? 'saving' : state.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: state.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && state.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }), _jsx("span", { id: describedBy, className: "type-body", children: 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"] })) : (state.error && _jsx(FieldError, { className: "type-caption", children: state.error })) })] }));
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 open, so a view nobody's editing never blocks anything.
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
- return () => window.removeEventListener('beforeunload', onBeforeUnload);
187
- }, [dirty]);
188
- return (_jsx(Dialog, { open: blocker.state === 'blocked', onOpenChange: (open) => {
189
- if (!open && blocker.state === 'blocked')
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 openField = useSyncExternalStore(store.subscribe, () => store.open, () => store.open);
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: [openField !== null && _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)))] }) }));
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,2 @@
1
+ export declare function passEscapeThrough(event: Event): void;
2
+ export declare function escapePassedThrough(event: Event): boolean;
@@ -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.1",
3
+ "version": "4.4.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": {