@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.
- package/README.md +1 -1
- package/conventions/docs/add-an-entity.md +113 -16
- package/conventions/docs/cell-patterns.md +176 -6
- package/conventions/docs/entity-plan-template.md +31 -4
- package/dist/api/gateway/errors.js +7 -1
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +15 -2
- package/dist/components/app/entity-view.js +24 -8
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/ui/field.js +2 -2
- package/dist/hooks/use-record-update.d.ts +71 -0
- package/dist/hooks/use-record-update.js +148 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +195 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -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
|
-
/**
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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;
|