@tristan2828/ui-foundation 3.12.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView`, `EntityForm`, `Markdown`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
22
22
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
@@ -189,6 +189,15 @@ is a filled-in example while the Widgets demo is still here).
189
189
  `src/routes/<entity>/<entity>-quick-actions.tsx`, **only when the
190
190
  plan's "View screen" lists `Quick actions`**: one component per
191
191
  control the plan names (below).
192
+ - `src/routes/widgets/widget-fields.tsx` →
193
+ `src/routes/<entity>/<entity>-fields.tsx`: **one control per field
194
+ the plan edits in place** (`Edit in place`), shared by the form
195
+ (through a `Controller`) and the view (through `editInPlace`), and
196
+ the Markdown long text's `RichTextEditor` (on the form whether or
197
+ not it edits in place). Every other field's control stays inline in
198
+ the form, as `widget-form.tsx`'s Category, Tags and Available From
199
+ do. No `Edit in place` line and no Markdown long text: no fields
200
+ file.
192
201
  - `src/routes/widgets/delete-widget-action.tsx` →
193
202
  `src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
194
203
  view's header: the confirm dialog, then `onDeleted` leaves for the
@@ -200,7 +209,8 @@ is a filled-in example while the Widgets demo is still here).
200
209
  view (below).
201
210
 
202
211
  **The view** is the read-only page for one record, at `/<entity>/:id`
203
- (apart from the plan's quick actions, if it lists any).
212
+ (apart from the plan's quick actions and fields edited in place, if it
213
+ lists any).
204
214
  `<EntityView>` owns its loading skeleton, its not-found state (another
205
215
  user's record is the same plain 404, with a way back to the list and
206
216
  no retry) and its error state with retry. The screen supplies what the
@@ -261,8 +271,7 @@ is a filled-in example while the Widgets demo is still here).
261
271
  breaks).
262
272
  - **Quick actions: only the fields the plan's `Quick actions` line
263
273
  names**, each a control that saves on its own (cell pattern 17,
264
- `widget-quick-actions.tsx`). A `single choice` in the header is a
265
- `Select` showing its badge (`WidgetStatusSelect`); a `yes/no` is a
274
+ `widget-quick-actions.tsx`). A `yes/no` is a
266
275
  `Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
267
276
  yes/no item field is a checkbox per item in a named group
268
277
  (`ChecklistItems`), whose change is a function of the record
@@ -273,9 +282,29 @@ is a filled-in example while the Widgets demo is still here).
273
282
  `content`): `EntityView` needs nothing new. **With no `Quick actions`
274
283
  line, every value is read-only** and there is no quick-actions file:
275
284
  the view is exactly the read-only one above.
276
- - **Editing in place isn't in the playbook yet** (a text or date field
277
- turning into its form control on the view). Don't build it: every
278
- other change goes through Edit.
285
+ - **Edit in place: only the fields the plan's `Edit in place` line
286
+ names** (cell pattern 18). Each becomes `editInPlace({ kind, value,
287
+ control, schema, save })` (`widgetEdits` in `widget-view.tsx`):
288
+ `value` is what the form's control starts from, `control` is the
289
+ shared control from `<entity>-fields.tsx` given the props it's
290
+ handed (`readOnly={props.disabled}`, `aria-label={props.label}`,
291
+ `aria-describedby={props.describedBy}`), `schema` is the form's own
292
+ rule (`<entity>FormSchema.shape.<field>`), and `save` is
293
+ `useEdit<Entity>Field(id).mutateAsync({ <field>: value })` (copy
294
+ `useEditWidgetField`: the single-field save with `optimistic: false,
295
+ toastOnError: false`). `kind` follows the control: `text` (one line;
296
+ a number or integer too), `long-text` (Markdown, `RichTextEditor`
297
+ with `autoFocus` and `onProblem={props.onProblem}`), `choice` (a
298
+ select or a single reference: its list opens with the field,
299
+ `defaultOpen`, non-modal, `onChange={(v) => props.commit(v)}`,
300
+ `onOpenChange={(open) => !open && props.cancel()}`; a yes/no switch
301
+ commits on change), `multi` (a multi reference; `isEqual` comparing
302
+ ids as a set). A field gets `edit`, a `content` section gets `edit`,
303
+ the title gets `titleEdit={{ label, ...edit }}`, and a header badge
304
+ wraps itself in `<EditableValue label edit layout="inline">`. The
305
+ shown value stays exactly as before. **With no `Edit in place` line,
306
+ nothing is editable in place** and none of this is copied. Never a
307
+ computed field or a sub-record list.
279
308
 
280
309
  **The table opens the view, and the form returns to it.** The title
281
310
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -373,7 +402,15 @@ is a filled-in example while the Widgets demo is still here).
373
402
  save answers if the plan has one, a refused save (an MSW override
374
403
  answering 422 with the reason on the field) goes back with the toast,
375
404
  the "Saving" spinner shows while nothing is disabled, and, for a
376
- list, two quick changes both land. If
405
+ list, two quick changes both land. Fields edited in place get their
406
+ own spec (copy `e2e/widget-edit-in-place.spec.ts`): only the plan's
407
+ fields offer it; each kind saves (Enter, a pick, Ctrl/Cmd+Enter,
408
+ leaving) with the PATCH it should send and "Saving…" while it waits;
409
+ unchanged sends nothing; Esc restores; the schema's refusal is never
410
+ sent; a 422 (an MSW override, the reason on the field) and a 500 stay
411
+ open with the draft and the reason; opening a second field saves the
412
+ first, or doesn't open if that fails; and leaving the page with
413
+ unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
377
414
  the plan has Markdown long text, a test forces a record whose text has
378
415
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
379
416
  shows as text. Add the view's spec to `playwright.config.ts`'s
@@ -394,7 +431,10 @@ is a filled-in example while the Widgets demo is still here).
394
431
  `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
395
432
  of a full record and of the sparse one to its `viewRoutes` (the table
396
433
  page is picked up from the sidebar automatically; the form and the view
397
- aren't in it).
434
+ aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
435
+ "editing in place" block for them: a field open, saving and refused,
436
+ the rich-text editor, an open list, and the leave prompt, in both
437
+ themes.
398
438
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
399
439
  anything beyond what this list covers; note ideas in the app's
400
440
  backlog instead.
@@ -404,9 +444,11 @@ is a filled-in example while the Widgets demo is still here).
404
444
  - `widget-quick-actions.tsx`, its `quick action: …` specs and
405
445
  `useSaveWidgetField` unless the plan has a `Quick actions` line (or,
406
446
  for the hook, a `yes/no` marked `toggle`). Copy only the controls for
407
- the fields the line names: a plan naming only its status gets the
408
- status picker, and the In Stock switch and the checklist's checkboxes
409
- are left out.
447
+ the fields the line names: a plan naming only its yes/no gets the
448
+ switch, and the checklist's checkboxes are left out.
449
+ - `widgetEdits`, `useEditWidgetField`, `widget-edit-in-place.spec.ts` and
450
+ the in-place a11y block unless the plan has an `Edit in place` line,
451
+ and then only for the fields it names.
410
452
 
411
453
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
412
454
  button group and `visibleColumns`) unless the plan's List screen lists
@@ -8,9 +8,9 @@ per-entity, and only the shape is reusable. Copy the markup into your
8
8
  Patterns 1–10 were each chosen for a real column, from a page of
9
9
  alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
10
10
  Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
- to its view) and 17 (quick action on the view) are the template's own
12
- references: Widget's In Stock and Name columns and its view render them,
13
- and their specs pin them. The other patterns from 11 on are
11
+ to its view), 17 (quick action on the view) and 18 (editable value) are
12
+ the template's own references: Widget's In Stock and Name columns and its
13
+ view render them, and their specs pin them. The other patterns from 11 on are
14
14
  marked **Unproven**: written ahead of a real
15
15
  column, so no app has tested them on real data yet. Prefer a proven
16
16
  pattern when one fits. When you use an unproven one, say so in the
@@ -397,22 +397,23 @@ cell: ({ row }) => (
397
397
 
398
398
  ## 17. Quick action on the view
399
399
 
400
- A value on the record's view that changes right where it's shown, saved on
401
- its own the moment it changes, without the form: a status picked from the
402
- header, a yes/no flipped, a sub-record item ticked. Widget's view is the
403
- reference (`src/routes/widgets/widget-quick-actions.tsx`): Status and In
404
- Stock in the header, Checklist items ticked in place.
400
+ A value on the record's view that changes right where it's shown, with one
401
+ click, saved on its own the moment it changes, without the form: a yes/no
402
+ flipped, a sub-record item ticked. Widget's view is the reference
403
+ (`src/routes/widgets/widget-quick-actions.tsx`): In Stock in the header,
404
+ Checklist items ticked in place.
405
405
 
406
406
  **Read-only is the default.** A value on the view is a control only when
407
- the plan's "View screen" names it under `Quick actions`; everything else
408
- renders as its table cell does, and changes through Edit. A quick action
409
- is for a value people change often and can change back as easily: never
410
- anything destructive, never text (that's editing in place, not built
411
- yet).
407
+ the plan's "View screen" names it under `Quick actions` (or `Edit in
408
+ place`, pattern 18); everything else renders as its table cell does, and
409
+ changes through Edit. A quick action is for a value people change often
410
+ and can change back as easily: never anything destructive. Anything you
411
+ type or pick from a list (text, a status) edits in place instead
412
+ (pattern 18); a status was a quick action in 3.12, and moved there in
413
+ 3.13.
412
414
 
413
415
  | The plan's field | The control | Accessible name |
414
416
  |---|---|---|
415
- | `single choice`, in the header | A `Select` (`size="sm"`) whose value and options render as the field's tone-mapped `Badge` (pattern 5), in place of the badge | The field's label: `Status` |
416
417
  | `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
417
418
  | A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
418
419
 
@@ -452,12 +453,108 @@ const save = useSaveWidgetField(widget.id) // one per control
452
453
  - **Saving shows, and blocks nothing.** Each control keeps its own
453
454
  "Saving" spinner in a slot that's always there (nothing moves), and
454
455
  nothing is disabled: not the other controls, not this one, not Edit.
455
- - **Keep the value's look.** The status keeps its tone-mapped badge
456
- inside the picker; the yes/no keeps its words; a ticked item keeps the
457
- muted text a done item had. The control only adds the affordance.
456
+ - **Keep the value's look.** The yes/no keeps its words; a ticked item
457
+ keeps the muted text a done item had. The control only adds the
458
+ affordance.
458
459
  - **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
459
460
  so each hook lives in a component and the view's sections stay plain
460
461
  data.
461
462
  - **Cost:** one click changes data, with no confirmation, and the server
462
463
  may still say no. Keep the list short: a view where every value is a
463
464
  control reads as a form.
465
+
466
+ ## 18. Editable value
467
+
468
+ A value on the record's view that turns into **the form's own control**
469
+ where it's shown, and saves when you leave it: a title, a status, a
470
+ price, notes, a list of linked records. Widget's view is the reference
471
+ (`src/routes/widgets/widget-view.tsx`): Name (the title), Status (its
472
+ header badge), Price, Description (rich text) and Extra Categories, with
473
+ the controls in `widget-fields.tsx`, the same ones the form uses.
474
+
475
+ The rule behind every part of it: **nothing typed is ever lost.**
476
+
477
+ **Which values.** Only those the plan's "View screen" names under `Edit
478
+ in place`; read-only is the default, and a value not named renders
479
+ exactly as it did. Single values only: text, long text, a single choice
480
+ (optional ones with their "not set" option), an integer or a rating, a
481
+ yes/no, a single or multi reference. Never a computed field, and never a
482
+ sub-record list (adding, removing and reordering items stays on the
483
+ form). The view's Edit button and the form stay.
484
+
485
+ **At rest: says it's editable without shouting.** The value reads as it
486
+ always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
487
+ a real button ("Edit <label>"), so Tab reaches it and Enter opens the
488
+ field, and its focus ring tints the value too. On a touch screen the
489
+ pencil always shows. A click anywhere on the value opens it, except on a
490
+ link inside it (a reference to a record with a view, a link in the
491
+ notes), which still navigates.
492
+
493
+ **Open: the form's control, nothing else.** The same labels, choices and
494
+ pickers as `EntityForm`; the field's label is its accessible name. No
495
+ Save button, no Undo:
496
+
497
+ | Kind (`editInPlace({ kind })`) | Saves on | Esc |
498
+ |---|---|---|
499
+ | `text`: one line | Enter, or leaving it | puts the saved value back |
500
+ | `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line) | the same |
501
+ | `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 |
502
+ | `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
503
+
504
+ Leaving means clicking or tabbing anywhere else. Unchanged closes with no
505
+ request; a value the form's schema refuses never leaves the browser and
506
+ shows the schema's message. Opening another value saves the open one
507
+ first; if that save fails, the open one stays and the other doesn't
508
+ open.
509
+
510
+ **Saving: still open, read-only, and says so.** Under the control,
511
+ "Saving…" with a spinner (`role="status"`) until the server agrees, then
512
+ it closes on the saved value. **Not optimistic**: the value only shows as
513
+ saved once it is, and the view shows the record the server returned (a
514
+ side effect included). The rest of the page stays usable.
515
+
516
+ **Refused: still open, with exactly what was typed and why.** The reason
517
+ under the control in the destructive text tone (`FieldError`), word for
518
+ word: a 422's field error as `EntityForm` shows it, or the error's
519
+ message (a 500, no answer). The control is `aria-invalid` and described
520
+ by the reason. Nothing reverts on its own, and there's no toast. Leaving
521
+ again retries; Esc gives up.
522
+
523
+ **Leaving the page** (a link, Back) while a value holds unsaved text asks
524
+ "Discard your changes?" first (Keep editing / Discard); closing the tab
525
+ asks through the browser. A link press doesn't save the field on its
526
+ way out: the prompt decides.
527
+
528
+ ```tsx
529
+ const editField = useEditWidgetField(widget.id) // optimistic: false, toastOnError: false
530
+ const price = editInPlace({
531
+ kind: 'text',
532
+ value: widget.price, // as the form's control holds it
533
+ schema: widgetFormSchema.shape.price, // the form's own rule
534
+ save: (price) => editField.mutateAsync({ price }),
535
+ control: (props) => (
536
+ <WidgetPriceInput id={props.id} value={props.value} onChange={props.onChange}
537
+ invalid={props.invalid} readOnly={props.disabled}
538
+ aria-label={props.label} aria-describedby={props.describedBy} />
539
+ ),
540
+ })
541
+ // A field: { label: 'Price', value: <…as the table shows it…>, edit: price }
542
+ // The title: <EntityView titleEdit={{ label: 'Name', ...name }} />
543
+ // A badge: <EditableValue label="Status" edit={status} layout="inline"><Badge>…</Badge></EditableValue>
544
+ ```
545
+
546
+ - **One control per field, shared with the form** (`<entity>-fields.tsx`):
547
+ each takes `value`/`onChange` and `readOnly`, and the form wraps it in a
548
+ `Controller`. Never a second, in-place-only control.
549
+ - **Long text written as Markdown edits as rich text** (`RichTextEditor`):
550
+ formatted where it's read, saved as Markdown with every block the person
551
+ didn't touch kept exactly as written. On the form too, so it's one
552
+ control.
553
+ - **The save is the single-field save** (`useRecordUpdate` with
554
+ `optimistic: false, toastOnError: false`, the entity's
555
+ `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
556
+ - **Keep the value's look at rest**: the badge stays a badge, the price
557
+ stays formatted. Only the open state looks like a form.
558
+ - **Cost:** a value that looks like text but edits on click can surprise.
559
+ Keep it to the fields the plan names, and leave the form for everything
560
+ else.
@@ -25,7 +25,8 @@ One or two sentences: what one record is, and who uses the screens.
25
25
  - *Demonstrated by the Widget reference* (copied directly): `text` (one
26
26
  line), `long text` (say `Markdown` when it's written in Markdown, like
27
27
  notes or a write-up: the view renders its headings, lists, tables and
28
- links, and it's never a table column), `decimal` (fixed places — say how many),
28
+ links, the form and editing in place edit it as formatted text, and
29
+ it's never a table column), `decimal` (fixed places — say how many),
29
30
  `date-time`, `email`, `yes/no` (a boolean that is always yes or no:
30
31
  say its default and the filter's three labels, e.g. Any stock /
31
32
  In stock / Out of stock), `single choice` (list the options, in display
@@ -133,18 +134,29 @@ except the quick actions, if any.
133
134
  - "Not set" labels: what an empty optional field reads as on the view
134
135
  (`dueDate`: No due date; `checklist`: No items). A field not listed here
135
136
  reads "Not set". A `yes/no` is never empty: it reads Yes or No.
136
- - Quick actions (optional; delete if none): the fields the view changes
137
- right where they show, each saved on its own the moment it changes,
138
- without the form. Every other value stays read-only. Each one is a
139
- `single choice` (picked from a list, usually a badge field), a `yes/no`
140
- (a switch), or a sub-records list (its yes/no item field ticked on each
141
- item). Only values people change often and can change back as easily.
137
+ - Quick actions (optional; delete if none): the values the view changes
138
+ with one click, each saved on its own the moment it changes, without
139
+ the form: a `yes/no` (a switch), or a sub-records list (its yes/no item
140
+ field ticked on each item). Only values people change often and can
141
+ change back as easily.
142
142
  Say any rule the server enforces on one (it can refuse the change) and
143
143
  any side effect (another field the server changes with it), so each
144
144
  gets a test; the view always shows the record the server returns.
145
145
 
146
- Quick actions: status (the server refuses Finished while the checklist
147
- has open items), checklist
146
+ Quick actions: checklist
147
+
148
+ - Edit in place (optional; delete if none): the fields that turn into
149
+ their form control where the view shows them and save when you leave
150
+ them (cell pattern 18): the title, a badge field, a field row, a long
151
+ text. Single values only: text, long text, a single choice, an integer
152
+ or a rating, a yes/no, a single or multi reference. Never a computed
153
+ field or a sub-records list (those stay on the form). Every value not
154
+ named here, or under Quick actions, stays read-only. Say any rule the
155
+ server enforces on one (its refusal shows under the field) and any
156
+ side effect:
157
+
158
+ Edit in place: title, status (the server refuses Finished while the
159
+ checklist has open items), dueDate, notes
148
160
 
149
161
  ## Screens and access
150
162
 
@@ -0,0 +1,76 @@
1
+ type SafeParse = (value: unknown) => {
2
+ success: true;
3
+ data: unknown;
4
+ } | {
5
+ success: false;
6
+ error: {
7
+ issues: readonly {
8
+ message: string;
9
+ }[];
10
+ };
11
+ };
12
+ /** What a field needs to edit in place, read fresh on every use. */
13
+ export type EditFieldConfig = {
14
+ /** The saved value, as the control holds it (the form's value for the field). */
15
+ value: unknown;
16
+ /** The form's zod schema for the field: a value it rejects never leaves the browser. */
17
+ schema?: {
18
+ safeParse: SafeParse;
19
+ };
20
+ /** Sends the value (the schema's output). Resolves once saved; rejects with an AppError. */
21
+ save: (value: unknown) => Promise<unknown>;
22
+ /** Whether the draft is still the saved value. Defaults to comparing as JSON. */
23
+ isEqual?: (a: unknown, b: unknown) => boolean;
24
+ };
25
+ export type EditFieldState = {
26
+ status: 'closed';
27
+ } | OpenFieldState;
28
+ export type OpenFieldState = {
29
+ status: 'open' | 'saving';
30
+ /** What the control shows: exactly what was typed. */
31
+ draft: unknown;
32
+ /** The saved value when the field opened. */
33
+ initial: unknown;
34
+ /** Why it can't save (the schema's or the server's words), shown under it. */
35
+ error: string | null;
36
+ /** A problem the control reports itself (rich text that can't save as it stands). */
37
+ problem: string | null;
38
+ };
39
+ export declare class EditInPlaceStore {
40
+ private configs;
41
+ private states;
42
+ private saving;
43
+ private openKey;
44
+ private listeners;
45
+ subscribe: (listener: () => void) => () => void;
46
+ private emit;
47
+ private set;
48
+ /** A field joins the view. `config` is read fresh each time it's needed. */
49
+ register(key: string, config: () => EditFieldConfig): () => void;
50
+ state(key: string): EditFieldState;
51
+ /** The field that's open, if any. */
52
+ get open(): string | null;
53
+ /** Whether leaving the page now would lose something: a draft that isn't saved. */
54
+ get dirty(): boolean;
55
+ /**
56
+ * Opens a field. The open one, if another, is saved first: if that
57
+ * fails it stays open and this one doesn't, and this returns false.
58
+ */
59
+ start(key: string): Promise<boolean>;
60
+ /** The control's value changed. Ignored while saving: the field is read-only then. */
61
+ change(key: string, draft: unknown): void;
62
+ /** The control can't produce a value to save as it stands (`null` once it can). */
63
+ report(key: string, problem: string | null): void;
64
+ /**
65
+ * Saves the field (leaving it, Enter, a choice picked). Resolves true
66
+ * once it's closed (saved, or unchanged), false if it stays open (the
67
+ * schema refused it, or the save failed). `draft`, when given, is the
68
+ * value to save (a choice saves the moment it's picked).
69
+ */
70
+ commit(key: string, draft?: unknown): Promise<boolean>;
71
+ /** Esc: closes and shows the saved value again. Not while a save is in flight. */
72
+ cancel(key: string): void;
73
+ /** Leaving the page anyway: whatever is open is dropped. */
74
+ discard(): void;
75
+ }
76
+ export {};
@@ -0,0 +1,159 @@
1
+ import { refusalReason } from '../../hooks/use-record-update.js';
2
+ const CLOSED = { status: 'closed' };
3
+ const sameAsJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
4
+ function messageOf(error) {
5
+ if (error && typeof error === 'object' && 'kind' in error)
6
+ return refusalReason(error);
7
+ if (error instanceof Error)
8
+ return error.message;
9
+ return 'The save failed. Try again.';
10
+ }
11
+ export class EditInPlaceStore {
12
+ configs = new Map();
13
+ states = new Map();
14
+ saving = new Map();
15
+ openKey = null;
16
+ listeners = new Set();
17
+ subscribe = (listener) => {
18
+ this.listeners.add(listener);
19
+ return () => {
20
+ this.listeners.delete(listener);
21
+ };
22
+ };
23
+ emit() {
24
+ for (const listener of this.listeners)
25
+ listener();
26
+ }
27
+ set(key, state) {
28
+ this.states.set(key, state);
29
+ if (state.status === 'closed' && this.openKey === key)
30
+ this.openKey = null;
31
+ this.emit();
32
+ }
33
+ /** A field joins the view. `config` is read fresh each time it's needed. */
34
+ register(key, config) {
35
+ this.configs.set(key, config);
36
+ return () => {
37
+ this.configs.delete(key);
38
+ this.states.delete(key);
39
+ if (this.openKey === key)
40
+ this.openKey = null;
41
+ };
42
+ }
43
+ state(key) {
44
+ return this.states.get(key) ?? CLOSED;
45
+ }
46
+ /** The field that's open, if any. */
47
+ get open() {
48
+ return this.openKey;
49
+ }
50
+ /** Whether leaving the page now would lose something: a draft that isn't saved. */
51
+ get dirty() {
52
+ if (this.openKey === null)
53
+ return false;
54
+ const state = this.state(this.openKey);
55
+ if (state.status === 'closed')
56
+ return false;
57
+ if (state.status === 'saving' || state.error !== null)
58
+ return true;
59
+ const isEqual = this.configs.get(this.openKey)?.().isEqual ?? sameAsJson;
60
+ return !isEqual(state.draft, state.initial);
61
+ }
62
+ /**
63
+ * Opens a field. The open one, if another, is saved first: if that
64
+ * fails it stays open and this one doesn't, and this returns false.
65
+ */
66
+ async start(key) {
67
+ if (this.openKey === key)
68
+ return true;
69
+ if (this.openKey !== null) {
70
+ const closed = await this.commit(this.openKey);
71
+ if (!closed)
72
+ return false;
73
+ // Another field may have opened while that save was in flight.
74
+ if (this.openKey !== null && this.openKey !== key)
75
+ return false;
76
+ }
77
+ const config = this.configs.get(key)?.();
78
+ if (!config)
79
+ return false;
80
+ this.openKey = key;
81
+ this.set(key, { status: 'open', draft: config.value, initial: config.value, error: null, problem: null });
82
+ return true;
83
+ }
84
+ /** The control's value changed. Ignored while saving: the field is read-only then. */
85
+ change(key, draft) {
86
+ const state = this.state(key);
87
+ if (state.status !== 'open')
88
+ return;
89
+ this.set(key, { ...state, draft });
90
+ }
91
+ /** The control can't produce a value to save as it stands (`null` once it can). */
92
+ report(key, problem) {
93
+ const state = this.state(key);
94
+ if (state.status === 'closed' || state.problem === problem)
95
+ return;
96
+ this.set(key, { ...state, problem });
97
+ }
98
+ /**
99
+ * Saves the field (leaving it, Enter, a choice picked). Resolves true
100
+ * once it's closed (saved, or unchanged), false if it stays open (the
101
+ * schema refused it, or the save failed). `draft`, when given, is the
102
+ * value to save (a choice saves the moment it's picked).
103
+ */
104
+ commit(key, draft) {
105
+ const inFlight = this.saving.get(key);
106
+ if (inFlight)
107
+ return inFlight;
108
+ let state = this.state(key);
109
+ if (state.status === 'closed')
110
+ return Promise.resolve(true);
111
+ if (draft !== undefined && state.status === 'open') {
112
+ state = { ...state, draft };
113
+ this.set(key, state);
114
+ }
115
+ const open = state;
116
+ const config = this.configs.get(key)?.();
117
+ if (!config)
118
+ return Promise.resolve(false);
119
+ if (open.problem) {
120
+ this.set(key, { ...open, error: open.problem });
121
+ return Promise.resolve(false);
122
+ }
123
+ const isEqual = config.isEqual ?? sameAsJson;
124
+ if (isEqual(open.draft, open.initial)) {
125
+ this.set(key, CLOSED);
126
+ return Promise.resolve(true);
127
+ }
128
+ const parsed = config.schema ? config.schema.safeParse(open.draft) : { success: true, data: open.draft };
129
+ if (!parsed.success) {
130
+ this.set(key, { ...open, error: parsed.error.issues[0]?.message ?? 'Check this value.' });
131
+ return Promise.resolve(false);
132
+ }
133
+ this.set(key, { ...open, status: 'saving', error: null });
134
+ const save = config
135
+ .save(parsed.data)
136
+ .then(() => {
137
+ this.set(key, CLOSED);
138
+ return true;
139
+ }, (error) => {
140
+ this.set(key, { ...open, status: 'open', error: messageOf(error) });
141
+ return false;
142
+ })
143
+ .finally(() => this.saving.delete(key));
144
+ this.saving.set(key, save);
145
+ return save;
146
+ }
147
+ /** Esc: closes and shows the saved value again. Not while a save is in flight. */
148
+ cancel(key) {
149
+ const state = this.state(key);
150
+ if (state.status !== 'open')
151
+ return;
152
+ this.set(key, CLOSED);
153
+ }
154
+ /** Leaving the page anyway: whatever is open is dropped. */
155
+ discard() {
156
+ if (this.openKey !== null)
157
+ this.cancel(this.openKey);
158
+ }
159
+ }
@@ -0,0 +1,88 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { EditInPlaceStore } from './edit-in-place-store.js';
3
+ /** What an editable value's control is given. Spread what applies onto the form's own control. */
4
+ export type EditControlProps<TDraft> = {
5
+ /** The control's id. */
6
+ id: string;
7
+ /** The draft: exactly what's been typed or picked. */
8
+ value: TDraft;
9
+ onChange: (value: TDraft) => void;
10
+ /**
11
+ * Save now. A choice calls it with the value picked (`commit(value)`):
12
+ * a choice saves the moment it's picked.
13
+ */
14
+ commit: (value?: TDraft) => void;
15
+ /** Close without saving: a list closed with nothing picked. */
16
+ cancel: () => void;
17
+ /** The save is in flight: show the draft, don't let it change. */
18
+ disabled: boolean;
19
+ /** The draft was refused; its reason is under it (`aria-describedby`). */
20
+ invalid: boolean;
21
+ /** The field's label: the control's accessible name. */
22
+ label: string;
23
+ /** The id of the line under the control (Saving…, or why it can't save). */
24
+ describedBy: string;
25
+ /** For a control that can't always produce a value to save (RichTextEditor's `onProblem`). */
26
+ onProblem: (message: string | null) => void;
27
+ };
28
+ /**
29
+ * How the value saves, which follows from its control:
30
+ * - `text`: one line. Enter saves (as does leaving it).
31
+ * - `long-text`: Ctrl/Cmd+Enter saves; Enter is a new line.
32
+ * - `choice`: one pick (a select, a switch, a single reference). Saves the
33
+ * moment it's picked; its list opens with the field.
34
+ * - `multi`: several picks (a multi reference). Saves on leaving it, or
35
+ * Ctrl/Cmd+Enter; Enter picks.
36
+ * Leaving the field saves every kind; Esc gives up.
37
+ */
38
+ export type EditKind = 'text' | 'long-text' | 'choice' | 'multi';
39
+ type SafeParseResult<TValue> = {
40
+ success: true;
41
+ data: TValue;
42
+ } | {
43
+ success: false;
44
+ error: {
45
+ issues: readonly {
46
+ message: string;
47
+ }[];
48
+ };
49
+ };
50
+ /** A field marked editable. Make one with `editInPlace({...})`. */
51
+ export type EditInPlace = {
52
+ kind: EditKind;
53
+ value: unknown;
54
+ control: (props: EditControlProps<unknown>) => ReactNode;
55
+ schema?: {
56
+ safeParse: (value: unknown) => SafeParseResult<unknown>;
57
+ };
58
+ save: (value: unknown) => Promise<unknown>;
59
+ isEqual?: (a: unknown, b: unknown) => boolean;
60
+ };
61
+ export type EditInPlaceOptions<TDraft, TValue> = {
62
+ kind: EditKind;
63
+ /** The saved value as the control holds it: what the form's control would start from. */
64
+ value: TDraft;
65
+ /** The form's own control for this field (one control per field, shared with the form). */
66
+ control: (props: EditControlProps<TDraft>) => ReactNode;
67
+ /**
68
+ * The form's schema for this field (`widgetFormSchema.shape.name`): a
69
+ * value it refuses never leaves the browser, and its message shows
70
+ * under the field. Its output is what `save` gets.
71
+ */
72
+ schema?: {
73
+ safeParse: (value: unknown) => SafeParseResult<TValue>;
74
+ };
75
+ /**
76
+ * Saves the value: the entity's single-field save (`useRecordUpdate`
77
+ * with `optimistic: false, toastOnError: false`), `mutateAsync` of the
78
+ * field's update. Rejects with the AppError; a 422's field error shows
79
+ * under the field word for word.
80
+ */
81
+ save: (value: TValue) => Promise<unknown>;
82
+ /** Whether a draft is still the saved value. Defaults to comparing as JSON. */
83
+ isEqual?: (a: TDraft, b: TDraft) => boolean;
84
+ };
85
+ /** Marks a field editable in place, typed end to end: draft → schema → save. */
86
+ export declare function editInPlace<TDraft, TValue = TDraft>(options: EditInPlaceOptions<TDraft, TValue>): EditInPlace;
87
+ export declare const EditInPlaceContext: import("react").Context<EditInPlaceStore | null>;
88
+ export {};