@lotics/ui 46.0.0 → 46.0.2
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/AGENTS.md +1 -1
- package/docs/composition.md +22 -0
- package/docs/data_entry.md +13 -0
- package/package.json +1 -1
- package/src/inline_edit.tsx +37 -6
- package/src/inline_markdown.tsx +1 -1
- package/src/inline_number_input.tsx +3 -1
- package/src/inline_text_input.tsx +1 -1
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, and where a commit sits. |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), and where a commit sits. |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it. GATES that outrank every treatment question, then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
package/docs/composition.md
CHANGED
|
@@ -467,6 +467,28 @@ INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if
|
|
|
467
467
|
cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
|
|
468
468
|
a dashboard. Card stat rails use `KPICard`.
|
|
469
469
|
|
|
470
|
+
## One fact, one surface — and no control that only reveals a number
|
|
471
|
+
|
|
472
|
+
Two rules that catch the same defect from opposite ends: a screen saying one thing twice.
|
|
473
|
+
|
|
474
|
+
**A selector and a detail view must not carry the same fact at the same weight.** When a compact
|
|
475
|
+
chooser sits above the thing it chooses — a set of presets over the options they combine, a filter
|
|
476
|
+
row over the facets it sets — the chooser is a SELECTOR and gets one line per item, and the surface
|
|
477
|
+
below carries the detail. Render both as cards and the reader has to work out that the parts add up
|
|
478
|
+
to the whole they already read, which is a puzzle the screen created for them. The tell is a section
|
|
479
|
+
whose two halves are the same height: whichever one is a *shortcut* to the other should be a
|
|
480
|
+
fraction of it.
|
|
481
|
+
|
|
482
|
+
**A control whose only job is to swap one number for another is more machinery than showing both.**
|
|
483
|
+
A billing-cycle toggle, a unit switch, a per-seat/per-month flip — each hides one figure behind a
|
|
484
|
+
click and adds a piece of state the rest of the screen has to respect. State both: the primary as
|
|
485
|
+
the headline, the alternative as a line under it. This also fixes an ordering problem the toggle
|
|
486
|
+
creates, because it asks the reader to decide something secondary (how it is counted) before they
|
|
487
|
+
have decided the thing itself (which one they want).
|
|
488
|
+
|
|
489
|
+
The toggle earns its place when the two states differ in more than a number — different rows,
|
|
490
|
+
different units, a different shape of answer. Swapping `1.700.000` for `2.000.000` is not that.
|
|
491
|
+
|
|
470
492
|
## Numbers, money, dates
|
|
471
493
|
|
|
472
494
|
- Free-standing numerals: `<Text tabular>` — always.
|
package/docs/data_entry.md
CHANGED
|
@@ -186,6 +186,19 @@ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read m
|
|
|
186
186
|
an in-flight write) — as does a write started by something other than an inline commit (a
|
|
187
187
|
dialog save, a debounced autosave).
|
|
188
188
|
|
|
189
|
+
**A field counts its own committed value as current until `value` catches up.** A caller updates
|
|
190
|
+
its stored copy only when the write RETURNS, so for the length of that round trip the `value` prop
|
|
191
|
+
still reads PRE-EDIT. Anything that re-opens the field in that window — a keyboard focus restore,
|
|
192
|
+
a second tap, a click into the neighbouring control — must not re-arm from that stale prop, because
|
|
193
|
+
the next blur would commit it as a fresh edit and the field would silently UNDO the edit it just
|
|
194
|
+
saved. `useInlineEdit` holds what it last sent and treats that as the current value for both the
|
|
195
|
+
re-arm and the "unchanged?" test, so a trailing commit of an already-sent value is a no-op; it also
|
|
196
|
+
renders that value (`edit.current`), so the old number never flashes back mid-write. The held copy
|
|
197
|
+
is dropped the moment `value` moves — whether it echoes the edit back or takes a different value
|
|
198
|
+
entirely — so it can never mask a change the field did not make. Nothing to wire up. This is not
|
|
199
|
+
theoretical: re-arming from the stale prop put a fee back to its pre-edit amount ~3s after an
|
|
200
|
+
operator corrected it, and the irreversible document minted seconds later billed the wrong figure.
|
|
201
|
+
|
|
189
202
|
### Keyboard entry — type → Tab → type
|
|
190
203
|
|
|
191
204
|
Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
|
package/package.json
CHANGED
package/src/inline_edit.tsx
CHANGED
|
@@ -37,6 +37,29 @@ export function useInlineEdit<T>(opts: {
|
|
|
37
37
|
const [draft, setDraftState] = useState<T>(value);
|
|
38
38
|
const [saving, setSaving] = useState(false);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const eq = useCallback((a: T, b: T) => (equals ? equals(a, b) : a === b), [equals]);
|
|
41
|
+
|
|
42
|
+
// What this field last handed to `onSave`, held until `value` catches up to it.
|
|
43
|
+
//
|
|
44
|
+
// `value` is the caller's stored copy, and a caller does not update it until its
|
|
45
|
+
// write returns — so for the length of that round trip the prop still reads the
|
|
46
|
+
// PRE-EDIT value. Anything that re-opens the field in that window (a focus
|
|
47
|
+
// restore, a second tap) would otherwise seed the draft from a value the user
|
|
48
|
+
// has already replaced, and the next blur would commit it as if it were a fresh
|
|
49
|
+
// edit: the field silently undoes the edit it just saved. In an app where the
|
|
50
|
+
// field holds an amount that is about to be invoiced, that writes the wrong
|
|
51
|
+
// number to a document nobody can re-issue.
|
|
52
|
+
//
|
|
53
|
+
// `before` is what `value` read at commit time. The moment `value` differs from
|
|
54
|
+
// it the parent has moved on — whether by echoing this edit back or by taking a
|
|
55
|
+
// different value from somewhere else — and the pending copy is dropped rather
|
|
56
|
+
// than masking a change the field did not make.
|
|
57
|
+
const pending = useRef<{ committed: T; before: T } | null>(null);
|
|
58
|
+
const held = pending.current;
|
|
59
|
+
const awaitingEcho = held !== null && eq(held.before, value);
|
|
60
|
+
if (held !== null && !awaitingEcho) pending.current = null;
|
|
61
|
+
/** The value the field must behave as if it holds — see `pending`. */
|
|
62
|
+
const current = awaitingEcho && held !== null ? held.committed : value;
|
|
40
63
|
// Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
|
|
41
64
|
// false; any trailing call — e.g. the blur that fires as Escape unmounts the
|
|
42
65
|
// input — is then a no-op, so the field commits/cancels exactly once.
|
|
@@ -58,11 +81,11 @@ export function useInlineEdit<T>(opts: {
|
|
|
58
81
|
}, []);
|
|
59
82
|
|
|
60
83
|
const begin = useCallback(() => {
|
|
61
|
-
setDraftState(
|
|
84
|
+
setDraftState(current);
|
|
62
85
|
setError(null);
|
|
63
86
|
active.current = true;
|
|
64
87
|
setEditing(true);
|
|
65
|
-
}, [
|
|
88
|
+
}, [current]);
|
|
66
89
|
|
|
67
90
|
const cancel = useCallback(() => {
|
|
68
91
|
if (!active.current) return;
|
|
@@ -75,8 +98,9 @@ export function useInlineEdit<T>(opts: {
|
|
|
75
98
|
async (next?: T) => {
|
|
76
99
|
if (!active.current) return;
|
|
77
100
|
const candidate = next === undefined ? draft : next;
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
// Against `current`, not `value`: re-committing what this field already
|
|
102
|
+
// saved is a no-op, not an edit back to the stored copy.
|
|
103
|
+
if (eq(candidate, current)) {
|
|
80
104
|
active.current = false;
|
|
81
105
|
setEditing(false);
|
|
82
106
|
return;
|
|
@@ -84,6 +108,7 @@ export function useInlineEdit<T>(opts: {
|
|
|
84
108
|
active.current = false;
|
|
85
109
|
setSaving(true);
|
|
86
110
|
setError(null);
|
|
111
|
+
pending.current = { committed: candidate, before: value };
|
|
87
112
|
try {
|
|
88
113
|
// Registered so an action pressed in the SAME gesture (the press that caused
|
|
89
114
|
// this blur) waits for the write instead of reading the pre-edit record —
|
|
@@ -92,6 +117,9 @@ export function useInlineEdit<T>(opts: {
|
|
|
92
117
|
await trackCommit((async () => onSave(candidate))());
|
|
93
118
|
setEditing(false);
|
|
94
119
|
} catch (e) {
|
|
120
|
+
// Nothing was stored, so there is no echo to wait for — drop the pending
|
|
121
|
+
// copy or the field would keep reporting a value the caller never took.
|
|
122
|
+
pending.current = null;
|
|
95
123
|
// Stay in edit mode so the entry isn't lost — show the error, re-arm.
|
|
96
124
|
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
97
125
|
active.current = true;
|
|
@@ -99,10 +127,13 @@ export function useInlineEdit<T>(opts: {
|
|
|
99
127
|
setSaving(false);
|
|
100
128
|
}
|
|
101
129
|
},
|
|
102
|
-
[draft, value, onSave,
|
|
130
|
+
[draft, value, current, onSave, eq, labels.saveError],
|
|
103
131
|
);
|
|
104
132
|
|
|
105
|
-
|
|
133
|
+
// `current` is what the field must SHOW as well as what it re-arms from: while a
|
|
134
|
+
// save is in flight the caller's `value` still reads pre-edit, and rendering that
|
|
135
|
+
// flashes the old number back at the operator mid-write.
|
|
136
|
+
return { editing, draft, setDraft, saving, error, begin, cancel, commit, current };
|
|
106
137
|
}
|
|
107
138
|
|
|
108
139
|
/**
|
package/src/inline_markdown.tsx
CHANGED
|
@@ -57,7 +57,7 @@ export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
|
57
57
|
// ...but the ✓/✕ verbs still key off a REAL edit: at rest there is nothing
|
|
58
58
|
// to accept and nothing to revert.
|
|
59
59
|
editOpen={edit.editing}
|
|
60
|
-
display={
|
|
60
|
+
display={edit.current}
|
|
61
61
|
placeholder={placeholder}
|
|
62
62
|
onBegin={edit.begin}
|
|
63
63
|
controls="blur"
|
|
@@ -45,7 +45,9 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
45
45
|
void edit.commit();
|
|
46
46
|
}, [controls, edit]);
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// `edit.current` rather than `value`: while a save is in flight the caller's
|
|
49
|
+
// copy still reads pre-edit, and showing that flashes the old number back.
|
|
50
|
+
const display = format ? format(edit.current) : edit.current == null ? "" : String(edit.current);
|
|
49
51
|
|
|
50
52
|
return (
|
|
51
53
|
<InlineEditFrame
|
|
@@ -158,7 +158,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
158
158
|
// accept and nothing to revert, so rendering them would both claim
|
|
159
159
|
// otherwise and hand the user two buttons that no-op.
|
|
160
160
|
editOpen={edit.editing}
|
|
161
|
-
display={
|
|
161
|
+
display={edit.current}
|
|
162
162
|
placeholder={placeholder}
|
|
163
163
|
onBegin={edit.begin}
|
|
164
164
|
controls={controls}
|