@tristan2828/ui-foundation 3.12.0 → 3.14.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 +57 -12
- package/conventions/docs/cell-patterns.md +369 -17
- package/conventions/docs/design-language.md +23 -4
- package/conventions/docs/entity-plan-template.md +21 -9
- 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 +10 -1
- package/dist/components/app/entity-view.js +20 -7
- 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/app/stage-circle.d.ts +20 -0
- package/dist/components/app/stage-circle.js +30 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/field.js +2 -2
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- 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 +196 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -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,
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
147
|
-
|
|
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 {};
|
|
@@ -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,10 +29,16 @@ 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;
|
|
38
|
+
/** The title is editable in place; `label` is the field's ("Name"). */
|
|
39
|
+
titleEdit?: EditInPlace & {
|
|
40
|
+
label: string;
|
|
41
|
+
};
|
|
33
42
|
/**
|
|
34
43
|
* A few status-like values beside the title, as `<Badge>`s, or as the
|
|
35
44
|
* control that changes one when the plan makes it a quick action (a
|
|
@@ -49,4 +58,4 @@ export type EntityViewProps = {
|
|
|
49
58
|
label: string;
|
|
50
59
|
};
|
|
51
60
|
};
|
|
52
|
-
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;
|