@tristan2828/ui-foundation 3.11.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.
@@ -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 {};
@@ -0,0 +1,10 @@
1
+ // Editing in place on a record's view: what a field passes to EntityView
2
+ // (or to an EditableValue in its header) to become editable where it's
3
+ // shown. The rules live in edit-in-place-store.ts; the markup in
4
+ // editable-value.tsx. Design language: cell-patterns.md "Editable value".
5
+ import { createContext } from 'react';
6
+ /** Marks a field editable in place, typed end to end: draft → schema → save. */
7
+ export function editInPlace(options) {
8
+ return options;
9
+ }
10
+ export const EditInPlaceContext = createContext(null);
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type EditInPlace } from './edit-in-place.js';
3
+ import type { EditInPlaceStore } from './edit-in-place-store.js';
4
+ export type EditableValueProps = {
5
+ /** The field's label: the control's accessible name, and the edit button's ("Edit Name"). */
6
+ label: string;
7
+ edit: EditInPlace;
8
+ /** The value as the view shows it. */
9
+ children: ReactNode;
10
+ /** `inline` in a heading or beside badges, `block` in a field row (default). */
11
+ layout?: 'inline' | 'block';
12
+ className?: string;
13
+ };
14
+ export declare function EditableValue({ label, edit, children, layout, className }: EditableValueProps): import("react").JSX.Element;
15
+ /**
16
+ * While a field holds something unsaved, leaving asks first: an in-app
17
+ * link or Back (React Router's blocker, so the app needs a data router),
18
+ * or closing the tab (`beforeunload`). Rendered by EntityView only while
19
+ * a field is open, so a view nobody's editing never blocks anything.
20
+ */
21
+ export declare function LeaveGuard({ store }: {
22
+ store: EditInPlaceStore;
23
+ }): import("react").JSX.Element;
@@ -0,0 +1,176 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A value on a record's view that turns into its form control where it's
3
+ // shown, and saves when you leave it (cell-patterns.md "Editable value").
4
+ // EntityView wraps every field, section and title marked `edit`; a header
5
+ // badge uses <EditableValue> itself. The rules are edit-in-place-store.ts.
6
+ //
7
+ // - Start: click the value (a link inside it still navigates), or tab to
8
+ // its edit button and press Enter. Hover and focus show it's editable.
9
+ // - Save: leave it (click or tab elsewhere), Enter on one line,
10
+ // Ctrl/Cmd+Enter anywhere, or pick a choice. Esc puts the saved value
11
+ // back. Unchanged closes with no request.
12
+ // - Saving: the control stays, read-only, with "Saving…" under it, until
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';
16
+ import { useBlocker } from 'react-router';
17
+ import { cn } from 'cn';
18
+ import { Button } from '../ui/button.js';
19
+ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '../ui/dialog.js';
20
+ import { FieldError } from '../ui/field.js';
21
+ import { Spinner } from '../ui/spinner.js';
22
+ import { EditInPlaceContext } from './edit-in-place.js';
23
+ // What a click on the shown value must leave alone: links navigate,
24
+ // buttons do their own thing.
25
+ const INTERACTIVE = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
26
+ // Where the caret goes once the control shows: the first match, in this
27
+ // order (a picker's text box before its chips' remove buttons).
28
+ const FOCUS_ORDER = [
29
+ 'input:not([type="hidden"]), textarea, [contenteditable="true"]',
30
+ '[role="combobox"], [role="switch"], button, [tabindex]:not([tabindex="-1"])',
31
+ ];
32
+ // Focus moving here doesn't save the field: a link that leaves the page
33
+ // (the leave prompt decides what happens to the draft) or the prompt itself.
34
+ const LEAVING = 'a[href]:not([target="_blank"]), [data-leave-guard]';
35
+ // The field's own control, given what it needs.
36
+ function Control({ render, ...props }) {
37
+ return render(props);
38
+ }
39
+ function useStore() {
40
+ const store = useContext(EditInPlaceContext);
41
+ if (!store)
42
+ throw new Error('<EditableValue> works inside an <EntityView>, which runs editing in place.');
43
+ return store;
44
+ }
45
+ export function EditableValue({ label, edit, children, layout = 'block', className }) {
46
+ const store = useStore();
47
+ const key = useId();
48
+ const controlId = `${key}-control`;
49
+ const describedBy = `${key}-status`;
50
+ // The latest config, read by the store whenever it needs it.
51
+ const config = useRef(edit);
52
+ useLayoutEffect(() => {
53
+ config.current = edit;
54
+ });
55
+ useEffect(() => store.register(key, () => config.current), [store, key]);
56
+ const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
57
+ const editButton = useRef(null);
58
+ const container = useRef(null);
59
+ // Set by a keyboard save or Esc: the caret goes back to the edit button.
60
+ const refocus = useRef(false);
61
+ const blurTimer = useRef(undefined);
62
+ const isOpen = state.status !== 'closed';
63
+ const wasOpen = useRef(false);
64
+ useEffect(() => {
65
+ if (isOpen && !wasOpen.current) {
66
+ for (const selector of FOCUS_ORDER) {
67
+ const target = container.current?.querySelector(selector);
68
+ if (target) {
69
+ target.focus();
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ // Closed: the caret goes back to the value's edit button after a
75
+ // keyboard save or Esc, or whenever closing left it nowhere (a pick
76
+ // from a list), so the keyboard never loses its place.
77
+ if (!isOpen && wasOpen.current && (refocus.current || document.activeElement === document.body)) {
78
+ refocus.current = false;
79
+ editButton.current?.focus();
80
+ }
81
+ wasOpen.current = isOpen;
82
+ }, [isOpen]);
83
+ useEffect(() => () => window.clearTimeout(blurTimer.current), []);
84
+ // Whether an Esc is closing a list inside the control. Read on the way
85
+ // down (window, capture): a picker closes its list from a document
86
+ // listener, before React sees the key, and by then it reads as closed.
87
+ const escapeClosesList = useRef(false);
88
+ useEffect(() => {
89
+ if (!isOpen)
90
+ return;
91
+ const onKeyDown = (event) => {
92
+ if (event.key !== 'Escape')
93
+ return;
94
+ escapeClosesList.current = Boolean(event.target?.closest?.('[aria-expanded="true"]'));
95
+ };
96
+ window.addEventListener('keydown', onKeyDown, true);
97
+ return () => window.removeEventListener('keydown', onKeyDown, true);
98
+ }, [isOpen]);
99
+ const start = useCallback(() => void store.start(key), [store, key]);
100
+ const commit = useCallback((value) => {
101
+ window.clearTimeout(blurTimer.current);
102
+ void store.commit(key, value);
103
+ }, [store, key]);
104
+ const cancel = useCallback(() => store.cancel(key), [store, key]);
105
+ const onShownClick = (event) => {
106
+ if (event.target.closest(INTERACTIVE))
107
+ return;
108
+ start();
109
+ };
110
+ const onKeyDown = (event) => {
111
+ if (state.status !== 'open')
112
+ return;
113
+ if (event.key === 'Escape') {
114
+ // A list open inside the control closes first; Esc again gives up.
115
+ if (event.defaultPrevented || escapeClosesList.current)
116
+ return;
117
+ event.preventDefault();
118
+ refocus.current = true;
119
+ cancel();
120
+ }
121
+ else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
122
+ event.preventDefault();
123
+ refocus.current = true;
124
+ commit();
125
+ }
126
+ else if (event.key === 'Enter' && edit.kind === 'text' && !event.shiftKey && !event.altKey) {
127
+ event.preventDefault();
128
+ refocus.current = true;
129
+ commit();
130
+ }
131
+ };
132
+ // Leaving the field saves it. Focus moving into the control's own list
133
+ // (a portal) still counts as inside: React sends its focus events here.
134
+ const onBlur = (event) => {
135
+ window.clearTimeout(blurTimer.current);
136
+ if (event.relatedTarget?.closest(LEAVING))
137
+ return;
138
+ blurTimer.current = window.setTimeout(() => {
139
+ if (store.state(key).status === 'open')
140
+ void store.commit(key);
141
+ }, 0);
142
+ };
143
+ const onFocus = () => window.clearTimeout(blurTimer.current);
144
+ if (state.status === 'closed') {
145
+ return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
146
+ }
147
+ const saving = state.status === 'saving';
148
+ 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 })) })] }));
149
+ }
150
+ /**
151
+ * While a field holds something unsaved, leaving asks first: an in-app
152
+ * link or Back (React Router's blocker, so the app needs a data router),
153
+ * or closing the tab (`beforeunload`). Rendered by EntityView only while
154
+ * a field is open, so a view nobody's editing never blocks anything.
155
+ */
156
+ export function LeaveGuard({ store }) {
157
+ const dirty = useSyncExternalStore(store.subscribe, () => store.dirty, () => store.dirty);
158
+ const blocker = useBlocker(({ currentLocation, nextLocation }) => store.dirty && currentLocation.pathname !== nextLocation.pathname);
159
+ useEffect(() => {
160
+ if (!dirty)
161
+ return;
162
+ const onBeforeUnload = (event) => {
163
+ event.preventDefault();
164
+ };
165
+ window.addEventListener('beforeunload', onBeforeUnload);
166
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
167
+ }, [dirty]);
168
+ return (_jsx(Dialog, { open: blocker.state === 'blocked', onOpenChange: (open) => {
169
+ if (!open && blocker.state === 'blocked')
170
+ blocker.reset();
171
+ }, 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: () => {
172
+ store.discard();
173
+ if (blocker.state === 'blocked')
174
+ blocker.proceed();
175
+ }, children: "Discard" })] })] }) }));
176
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { AppError } from '../../api/contracts.js';
3
+ import { type EditInPlace } from './edit-in-place.js';
3
4
  export type EntityViewField = {
4
5
  label: string;
5
6
  /**
@@ -10,6 +11,8 @@ export type EntityViewField = {
10
11
  value: ReactNode;
11
12
  /** What an empty value reads as: the plan's "not set" label. Defaults to "Not set". */
12
13
  emptyLabel?: string;
14
+ /** Editable in place: `editInPlace({...})`. Unmarked, the value is read-only. */
15
+ edit?: EditInPlace;
13
16
  };
14
17
  /**
15
18
  * A titled card on the view: either label/value rows (`fields`), or one
@@ -26,11 +29,21 @@ export type EntityViewSection = {
26
29
  title: string;
27
30
  content: ReactNode;
28
31
  emptyLabel?: string;
32
+ /** The block is editable in place (long text), named by the section's title. */
33
+ edit?: EditInPlace;
29
34
  };
30
35
  export type EntityViewProps = {
31
36
  /** The record's name or title, the page's <h1>. */
32
37
  title?: string;
33
- /** A few status-like values beside the title, as `<Badge>`s. */
38
+ /** The title is editable in place; `label` is the field's ("Name"). */
39
+ titleEdit?: EditInPlace & {
40
+ label: string;
41
+ };
42
+ /**
43
+ * A few status-like values beside the title, as `<Badge>`s, or as the
44
+ * control that changes one when the plan makes it a quick action (a
45
+ * status `Select`, a yes/no `Switch`).
46
+ */
34
47
  badges?: ReactNode;
35
48
  /** The header's actions: an Edit button, a Delete with its confirm dialog. */
36
49
  actions?: ReactNode;
@@ -45,4 +58,4 @@ export type EntityViewProps = {
45
58
  label: string;
46
59
  };
47
60
  };
48
- export declare function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
61
+ export declare function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -12,10 +12,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // is that an empty value reads as the plan's "not set" label, never a
13
13
  // blank.
14
14
  //
15
- // A value can be any markup, a control included, and `actions` takes any
16
- // buttons: that's the room left for quick actions on this page (a toggle,
17
- // a status change, ticking a checklist item) without the form.
18
- import { useId } from 'react';
15
+ // A value can be any markup, a control included, and so can `badges`:
16
+ // that's where quick actions go (cell pattern 17), a value the plan marks
17
+ // as changed straight from this page and saved on its own through
18
+ // `useRecordUpdate`: a status picked in the header, a yes/no switch,
19
+ // checklist items ticked in place.
20
+ //
21
+ // Editing in place (cell pattern 18): a field, a `content` section or the
22
+ // title marked `edit` turns into its form control where it's shown, and
23
+ // saves when you leave it. This component runs it (one field open at a
24
+ // time, the leave-page prompt); EditableValue is each value's side, and a
25
+ // header badge uses it directly. A field not marked renders exactly as
26
+ // before. Edit, in `actions`, stays for everything else.
27
+ import { useId, useState, useSyncExternalStore } from 'react';
19
28
  import { Link } from 'react-router';
20
29
  import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
21
30
  import { ErrorState } from './error-state.js';
@@ -23,6 +32,9 @@ import { Button } from '../ui/button.js';
23
32
  import { Card, CardContent, CardHeader } from '../ui/card.js';
24
33
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
25
34
  import { Skeleton } from '../ui/skeleton.js';
35
+ import { EditInPlaceContext } from './edit-in-place.js';
36
+ import { EditInPlaceStore } from './edit-in-place-store.js';
37
+ import { EditableValue, LeaveGuard } from './editable-value.js';
26
38
  const DEFAULT_EMPTY_LABEL = 'Not set';
27
39
  function isEmptyValue(value) {
28
40
  return (value === null ||
@@ -40,16 +52,20 @@ function orEmptyLabel(value, emptyLabel) {
40
52
  // Label beside value from `sm` up; below it (a phone) the label sits
41
53
  // above its value, so nothing is squeezed into a narrow second column.
42
54
  function FieldRow({ field }) {
43
- return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(field.value, field.emptyLabel) })] }));
55
+ return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
44
56
  }
45
57
  function ViewSection({ section }) {
46
58
  const headingId = useId();
47
- return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(section.content, section.emptyLabel) })) })] }) }));
59
+ return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
48
60
  }
49
61
  function EntityViewSkeleton() {
50
62
  return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
51
63
  }
52
- export function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }) {
64
+ export function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }) {
65
+ // Editing in place: one store per view, and the leave-page prompt only
66
+ // while a field is open.
67
+ const [store] = useState(() => new EditInPlaceStore());
68
+ const openField = useSyncExternalStore(store.subscribe, () => store.open, () => store.open);
53
69
  if (error?.kind === 'notfound') {
54
70
  // A record that doesn't exist and one that belongs to another user are
55
71
  // the same plain 404, so this never says which. Retrying can't help.
@@ -59,5 +75,5 @@ export function EntityView({ title, badges, actions, sections, isLoading, error,
59
75
  return _jsx(ErrorState, { error: error, onRetry: onRetry });
60
76
  if (isLoading)
61
77
  return _jsx(EntityViewSkeleton, {});
62
- return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [_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-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: 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 })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }));
78
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", 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 })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }) }));
63
79
  }
@@ -0,0 +1,2 @@
1
+ import type { RichTextEditorProps } from './rich-text-editor.js';
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;