@lotics/ui 27.7.0 → 27.8.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/docs/catalog.md +7 -2
- package/docs/data_entry.md +31 -14
- package/package.json +1 -1
- package/src/inline_edit.tsx +31 -3
- package/src/inline_text_input.tsx +70 -9
- package/src/text_utils.ts +21 -0
package/docs/catalog.md
CHANGED
|
@@ -340,7 +340,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
340
340
|
- **`text_utils`** — text/typography plumbing: `getTextColor` (the TextColor→hex map incl.
|
|
341
341
|
the AA-cleared valence set), the Inter `fontFamily*` stacks, and `getInputTextStyle` /
|
|
342
342
|
`getInputLineHeight` — the 16px-mobile/14px-desktop input contract that stops Safari iOS
|
|
343
|
-
auto-zoom; only for hand-rolled raw inputs.
|
|
343
|
+
auto-zoom; only for hand-rolled raw inputs. It carries the **ink** as well as the size,
|
|
344
|
+
because omitting `color` on a native `<input>` does not inherit — it falls to the UA
|
|
345
|
+
default, pure black, against the `zinc-900` every resting value beside it uses. A field
|
|
346
|
+
whose text darkens the instant it is focused reads as the text MOVING (at 14px a darkness
|
|
347
|
+
change re-weights every stroke's antialiasing), which is a bug no box measurement can
|
|
348
|
+
see. Hand-rolling a raw input means taking this style, not just its font size.
|
|
344
349
|
|
|
345
350
|
### Theming, locale & tokens
|
|
346
351
|
|
|
@@ -715,7 +720,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
715
720
|
EVERY `Inline*` editor takes it. `trailing` is decoration only (a chevron, a spinner): it
|
|
716
721
|
renders INSIDE the press target, so a button there would be a button in a button.
|
|
717
722
|
With `actions` the field renders through ONE shell that owns the surface in BOTH modes — so
|
|
718
|
-
the verbs never move when the editor
|
|
723
|
+
the verbs never move when the editor opens, the control inside goes `seamless`
|
|
719
724
|
(drawing no second box), hover is tracked on the box (react-native-web hands a parent's hover
|
|
720
725
|
to the innermost pressable, so a control-tracked border drops out as the pointer crosses a
|
|
721
726
|
verb), and the focus ring paints on the whole FIELD via focus-within, not on the value region.
|
package/docs/data_entry.md
CHANGED
|
@@ -46,8 +46,20 @@ narrow containers. A right-floated button aligns to nothing; only OVERLAY footer
|
|
|
46
46
|
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|
|
47
47
|
form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
|
|
48
48
|
chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
|
|
49
|
-
layout), click OR keyboard focus
|
|
50
|
-
point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"`
|
|
49
|
+
layout), click OR keyboard focus opens it for editing **with nothing moving** (zero reflow, the
|
|
50
|
+
whole point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"`
|
|
51
|
+
(✓ / ✕).
|
|
52
|
+
|
|
53
|
+
**How "nothing moves" is achieved differs by editor, and the difference is a rule.** A field whose
|
|
54
|
+
resting value is the RAW string (`InlineTextInput`) is ONE `<input>` in both states — it never
|
|
55
|
+
swaps elements, and `editing` is a style state driven by focus. Two elements cannot be made to
|
|
56
|
+
draw the same string identically: matching the padding, the border and the ink still leaves an
|
|
57
|
+
`<input>` centring by FONT METRICS where a `<div>` positions by LINE BOX, a quarter-CSS-pixel
|
|
58
|
+
residual that no property closes, is invisible to `getBoundingClientRect` (it reports the rounded
|
|
59
|
+
box), and lands on a real device pixel at fractional scaling. Where the resting display is
|
|
60
|
+
FORMATTED and the editor shows something else (`1,250 kg` → `1250`, `31/07/2026` → a segmented
|
|
61
|
+
field), the string changes anyway, so those editors do swap and should. **The rule for a new
|
|
62
|
+
editor: same string in both states ⇒ one element; different string ⇒ swap.**
|
|
51
63
|
|
|
52
64
|
**Commit-on-blur would race the press that caused it — the kit closes that race for you.**
|
|
53
65
|
Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
|
|
@@ -77,15 +89,16 @@ dialog save, a debounced autosave).
|
|
|
77
89
|
### Keyboard entry — type → Tab → type
|
|
78
90
|
|
|
79
91
|
Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
**keyboard** close (Enter/Escape) leaves focus on
|
|
86
|
-
resting view so the next Tab continues from the
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
SWAP editor — `InlineNumberInput`, `InlineTimePicker`, `InlineDatePicker` — opens edit mode
|
|
93
|
+
immediately with the input focused; commit-on-blur then makes Tab itself the commit, so the chain
|
|
94
|
+
is type → Tab → type with the next editor already open. Pointer focus never auto-opens (mousedown
|
|
95
|
+
records "pointer" modality before focus fires — `interaction_modality.ts`), so the click path is
|
|
96
|
+
exactly what it always was. `InlineTextInput` needs none of this — its input is always mounted, so
|
|
97
|
+
focus IS the edit by either route. When a **keyboard** close (Enter/Escape) leaves focus on
|
|
98
|
+
`<body>`, a swap editor returns focus to its resting view so the next Tab continues from the
|
|
99
|
+
field (the always-mounted one never loses focus, so there is nothing to restore). A **pointer**
|
|
100
|
+
close (click ✓/✕, or click away onto non-focusable space) does NOT restore — a mouse user has no
|
|
101
|
+
next Tab to preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
|
|
89
102
|
restore is gated on modality via `shouldRestoreFocusOnClose`).
|
|
90
103
|
|
|
91
104
|
**Typed dates.** `InlineDatePicker`'s keyboard mode is an internal segmented date field
|
|
@@ -131,10 +144,14 @@ One per type:
|
|
|
131
144
|
a multi `OptionList` (checkbox rows), CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
|
|
132
145
|
field.
|
|
133
146
|
|
|
134
|
-
They split across two shared shells. The same-height **
|
|
147
|
+
They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
|
|
135
148
|
`InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
|
|
136
|
-
**`useInlineEdit`** hook (the
|
|
137
|
-
input
|
|
149
|
+
**`useInlineEdit`** hook (the draft buffer, async save, and — for the two that swap — the view⇄edit
|
|
150
|
+
toggle) — pass a custom input to that pair to join the family. On the frame, **`editing` means an
|
|
151
|
+
input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
|
|
152
|
+
host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
|
|
153
|
+
which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
|
|
154
|
+
field they would be both a false claim and dead buttons. The **popover** editors (`InlineSelect`,
|
|
138
155
|
`InlineMemberSelect`, `InlineDatePicker`) instead compose **`InlineEditView`**
|
|
139
156
|
(the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
|
|
140
157
|
error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
|
package/package.json
CHANGED
package/src/inline_edit.tsx
CHANGED
|
@@ -34,7 +34,7 @@ export function useInlineEdit<T>(opts: {
|
|
|
34
34
|
const { value, onSave, equals } = opts;
|
|
35
35
|
const labels = useLoticsLocale().inline;
|
|
36
36
|
const [editing, setEditing] = useState(false);
|
|
37
|
-
const [draft,
|
|
37
|
+
const [draft, setDraftState] = useState<T>(value);
|
|
38
38
|
const [saving, setSaving] = useState(false);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
40
40
|
// Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
|
|
@@ -42,8 +42,23 @@ export function useInlineEdit<T>(opts: {
|
|
|
42
42
|
// input — is then a no-op, so the field commits/cancels exactly once.
|
|
43
43
|
const active = useRef(false);
|
|
44
44
|
|
|
45
|
+
// A keystroke IS an edit, so writing the draft re-arms the field. `editing` and
|
|
46
|
+
// `draft` must never disagree: with `editing` false the field renders the STORED
|
|
47
|
+
// value, so the keystroke is swallowed AND the trailing blur no-ops — the user
|
|
48
|
+
// types into a dead field and the text is gone. That state was unreachable while
|
|
49
|
+
// an editor mounted on demand (Escape unmounted the input, so there was nothing
|
|
50
|
+
// left to type into); it exists the moment one stays mounted, which
|
|
51
|
+
// `InlineTextInput` now does — Escape reverts and keeps the caret. Re-arming
|
|
52
|
+
// cannot double-commit: the exactly-once guard exists to swallow the blur that
|
|
53
|
+
// TRAILS an exit, and a keystroke after that exit is a new edit that must save.
|
|
54
|
+
const setDraft = useCallback((next: T) => {
|
|
55
|
+
active.current = true;
|
|
56
|
+
setEditing(true);
|
|
57
|
+
setDraftState(next);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
45
60
|
const begin = useCallback(() => {
|
|
46
|
-
|
|
61
|
+
setDraftState(value);
|
|
47
62
|
setError(null);
|
|
48
63
|
active.current = true;
|
|
49
64
|
setEditing(true);
|
|
@@ -139,7 +154,19 @@ export function useInlineEditFocusRestore(editing: boolean, restore: () => void)
|
|
|
139
154
|
export type InlineEditVariant = "framed" | "bare";
|
|
140
155
|
|
|
141
156
|
interface InlineEditFrameProps {
|
|
157
|
+
/** An input is MOUNTED inside the frame — which element to render, and whether
|
|
158
|
+
* the surface drops its resting padding to host one. For an editor that swaps
|
|
159
|
+
* this is the same thing as "an edit is open", which is why it was one flag. */
|
|
142
160
|
editing: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Whether an edit is actually OPEN, for an editor where that differs from
|
|
163
|
+
* `editing` — `InlineTextInput` mounts its input at REST, so it passes
|
|
164
|
+
* `editing` constant. Only the ✓/✕ verbs read this: they say "there is an edit
|
|
165
|
+
* to accept or reject", so on a resting field they are a false claim AND dead
|
|
166
|
+
* (`commit`/`cancel` both no-op with nothing active). Defaults to `editing`,
|
|
167
|
+
* which is the truth for every editor whose input arrives with the edit.
|
|
168
|
+
*/
|
|
169
|
+
editOpen?: boolean;
|
|
143
170
|
/** The formatted current value, shown in view mode. Empty → placeholder. */
|
|
144
171
|
display: string;
|
|
145
172
|
placeholder?: string;
|
|
@@ -467,6 +494,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
467
494
|
const labels = useLoticsLocale().inline;
|
|
468
495
|
const {
|
|
469
496
|
editing,
|
|
497
|
+
editOpen,
|
|
470
498
|
display,
|
|
471
499
|
placeholder,
|
|
472
500
|
onBegin,
|
|
@@ -582,7 +610,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
582
610
|
) : null}
|
|
583
611
|
</View>
|
|
584
612
|
)}
|
|
585
|
-
{controls === "buttons" ? (
|
|
613
|
+
{controls === "buttons" && (editOpen ?? editing) ? (
|
|
586
614
|
<View style={styles.buttons}>
|
|
587
615
|
<IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
|
|
588
616
|
<IconButton icon="x" color="secondary" size="sm" tooltip={labels.cancel} onPress={onCancel} disabled={saving} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
3
4
|
import { TextInputField } from "./text_input_field";
|
|
4
5
|
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
6
|
|
|
@@ -13,15 +14,16 @@ export interface InlineTextInputProps {
|
|
|
13
14
|
* an explicit ✓ saves and ✕ reverts. */
|
|
14
15
|
controls?: InlineEditControls;
|
|
15
16
|
disabled?: boolean;
|
|
16
|
-
/** Strike + mute the
|
|
17
|
+
/** Strike + mute the value (a completed item that stays editable). It stays
|
|
18
|
+
* struck while being edited — the field is one element, so a treatment that
|
|
19
|
+
* dropped on focus would be exactly the jump this control exists to avoid. */
|
|
17
20
|
struck?: boolean;
|
|
18
21
|
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
19
22
|
variant?: InlineEditVariant;
|
|
20
23
|
/**
|
|
21
24
|
* Line budget for a value a reader has to read WHOLE rather than recognise —
|
|
22
25
|
* a payment term, an address, a clause. Default 1 (the single-line field).
|
|
23
|
-
* Above 1 the
|
|
24
|
-
* multiline at the same height, so the row does not reflow on click. Enter
|
|
26
|
+
* Above 1 the field is that many lines tall and wraps, in both states. Enter
|
|
25
27
|
* then inserts a newline; the field commits on blur (or the ✓ in "buttons").
|
|
26
28
|
*/
|
|
27
29
|
numberOfLines?: number;
|
|
@@ -34,10 +36,14 @@ export interface InlineTextInputProps {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* An inline-editable single-line text value: reads as plain text,
|
|
38
|
-
*
|
|
39
|
-
* same height, so the form never reflows. The preferred control for editing a
|
|
39
|
+
* An inline-editable single-line text value: reads as plain text, darkens on
|
|
40
|
+
* hover, and opens for editing on focus. The preferred control for editing a
|
|
40
41
|
* value in a dense record / detail surface.
|
|
42
|
+
*
|
|
43
|
+
* It is ONE `<input>` in both states — never a resting view swapped for an
|
|
44
|
+
* editor — so nothing about the value can move when it is focused. See the
|
|
45
|
+
* comment on `editing` below for why that is load-bearing rather than an
|
|
46
|
+
* implementation detail.
|
|
41
47
|
*/
|
|
42
48
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
43
49
|
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
|
|
@@ -64,7 +70,33 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
64
70
|
|
|
65
71
|
return (
|
|
66
72
|
<InlineEditFrame
|
|
67
|
-
editing
|
|
73
|
+
// ALWAYS the input — this editor never swaps elements, and `editing` is a
|
|
74
|
+
// STYLE state (focus) rather than a mount.
|
|
75
|
+
//
|
|
76
|
+
// The swap was the bug. A resting `Text` and an `<input>` must render the
|
|
77
|
+
// same string identically, and nothing enforces it: the padding drifted
|
|
78
|
+
// (8px), then the transparent border (1px), then the ink (zinc-900 vs the
|
|
79
|
+
// UA's black) — each fixed in turn, each revealing the next. The last one
|
|
80
|
+
// cannot be fixed at all: measured on an interior 4x crop with no border or
|
|
81
|
+
// ring in frame, the glyph mass sits 1 DEVICE pixel lower while editing
|
|
82
|
+
// (row centroid 12.985 -> 13.985, identical ink, identical column
|
|
83
|
+
// centroid). Both paths compute a 10px text top, so it is not a padding
|
|
84
|
+
// mistake — an `<input>` centres its text by FONT METRICS and a `<div>`
|
|
85
|
+
// positions it by LINE BOX, and the residual is a quarter of a CSS pixel.
|
|
86
|
+
// No amount of matching properties closes it; only rendering one element
|
|
87
|
+
// does. Hence: one element.
|
|
88
|
+
//
|
|
89
|
+
// Scope is this editor alone. Number, date and time keep the swap, because
|
|
90
|
+
// their resting display is FORMATTED (`1,250 kg`, `31/07/2026`) and their
|
|
91
|
+
// editor shows something else entirely — the string changes, so a quarter
|
|
92
|
+
// pixel is not perceptible and an always-input would buy nothing while
|
|
93
|
+
// costing reformat-on-blur inside a field the user is typing in.
|
|
94
|
+
editing
|
|
95
|
+
// ...but the ✓/✕ verbs still key off a REAL edit. `editing` above is a
|
|
96
|
+
// mount decision now, and the verbs are not: at rest there is nothing to
|
|
97
|
+
// accept and nothing to revert, so rendering them would both claim
|
|
98
|
+
// otherwise and hand the user two buttons that no-op.
|
|
99
|
+
editOpen={edit.editing}
|
|
68
100
|
display={value}
|
|
69
101
|
placeholder={placeholder}
|
|
70
102
|
onBegin={edit.begin}
|
|
@@ -81,15 +113,44 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
81
113
|
actions={actions}
|
|
82
114
|
>
|
|
83
115
|
<TextInputField
|
|
84
|
-
|
|
116
|
+
// The DRAFT only exists once editing has begun; before that the field
|
|
117
|
+
// must show the record's value, or an external update would be masked by
|
|
118
|
+
// a stale draft from the last edit (`begin` is what syncs them).
|
|
119
|
+
value={edit.editing ? edit.draft : value}
|
|
85
120
|
onChangeText={edit.setDraft}
|
|
121
|
+
// Focus IS begin. There is no press-to-open any more: the element the
|
|
122
|
+
// user clicks is already the editor, so the gesture that used to mount it
|
|
123
|
+
// now just opens a draft on it. This is also why `autoFocus` is GONE —
|
|
124
|
+
// mounted permanently, it would have every text field on a record fight
|
|
125
|
+
// for focus on load.
|
|
126
|
+
onFocus={edit.begin}
|
|
86
127
|
onBlur={onBlur}
|
|
87
128
|
onKeyPress={onKeyPress}
|
|
88
|
-
autoFocus
|
|
89
129
|
multiline={multiline}
|
|
90
130
|
numberOfLines={numberOfLines}
|
|
91
131
|
placeholder={placeholder}
|
|
92
132
|
accessibilityLabel={accessibilityLabel}
|
|
133
|
+
// `disabled`, NOT `editable`: TextInputField derives editability as
|
|
134
|
+
// `!disabled || editable`, so passing `editable` alone can never turn it
|
|
135
|
+
// OFF. With the resting button gone, this is the only thing standing
|
|
136
|
+
// between a read-only field and a typable one.
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
// Everything the resting VIEW used to draw, the input now draws, because
|
|
139
|
+
// there is no resting view left to draw it. Both of these were its job:
|
|
140
|
+
//
|
|
141
|
+
// `bare` — the transparent resting edge. Hover and the focus ring are
|
|
142
|
+
// applied after `style` inside `TextInputField`, so it still darkens on
|
|
143
|
+
// hover and still rings on focus, from the element that now owns it.
|
|
144
|
+
//
|
|
145
|
+
// `struck` — a completed item's value, line-through and muted. This was
|
|
146
|
+
// applied in `fieldContent`, which only ever runs in the resting view, so
|
|
147
|
+
// moving to one element silently dropped it: a done task on the board kept
|
|
148
|
+
// its title upright. Anything else the view used to render has to move the
|
|
149
|
+
// same way, or it goes the same way — quietly.
|
|
150
|
+
style={[
|
|
151
|
+
variant === "bare" ? { borderColor: "transparent", backgroundColor: "transparent" } : null,
|
|
152
|
+
struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null,
|
|
153
|
+
]}
|
|
93
154
|
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
94
155
|
seamless={actions != null}
|
|
95
156
|
/>
|
package/src/text_utils.ts
CHANGED
|
@@ -73,6 +73,25 @@ export const INPUT_LINE_HEIGHT_DESKTOP = 20;
|
|
|
73
73
|
export const inputTextStyleWeb = {
|
|
74
74
|
fontSize: "var(--input-font-size)",
|
|
75
75
|
lineHeight: "var(--input-line-height)",
|
|
76
|
+
// THE INK IS PART OF THE CONTRACT, because leaving it out does not mean
|
|
77
|
+
// "inherit" — a native <input> ignores inherited colour and falls to the UA
|
|
78
|
+
// default, PURE BLACK, while every resting value beside it renders `zinc-900`.
|
|
79
|
+
// So a field's own text changed colour the instant it was focused: measured
|
|
80
|
+
// rgb(24,24,27) at rest against rgb(0,0,0) editing, on an unremarkable text
|
|
81
|
+
// field. Nothing moved — which is why box geometry, insets, line boxes, fonts
|
|
82
|
+
// and five widths all measured identical — but at 14px a darkness change
|
|
83
|
+
// re-weights the antialiasing of every stroke, and the eye reads that as the
|
|
84
|
+
// text shifting on focus. An EMPTY field hid it completely, since a placeholder
|
|
85
|
+
// sets its own colour and therefore never changed.
|
|
86
|
+
//
|
|
87
|
+
// It lives here rather than in one component because six inputs share this
|
|
88
|
+
// contract, and an ink re-picked per input is the drift that produced the bug.
|
|
89
|
+
// `getTextColor()` for the same reason — the default ink has ONE definition and
|
|
90
|
+
// an input is not the place to restate it. This is a DEFAULT, not a lock: every
|
|
91
|
+
// consumer applies the caller's `style` after this one, so an app that wants a
|
|
92
|
+
// different ink still sets it. (It is not the brand accent either way — the theme
|
|
93
|
+
// is deliberately accent-only, and body ink is not themed.)
|
|
94
|
+
color: getTextColor(),
|
|
76
95
|
};
|
|
77
96
|
|
|
78
97
|
/**
|
|
@@ -90,6 +109,8 @@ export const inputTextStyleRN = inputTextStyleWeb as unknown as TextStyle;
|
|
|
90
109
|
const inputTextStyleNative: TextStyle = {
|
|
91
110
|
fontSize: 14,
|
|
92
111
|
lineHeight: 20,
|
|
112
|
+
// Same ink as the web contract above — an input's text is body ink, not black.
|
|
113
|
+
color: getTextColor(),
|
|
93
114
|
};
|
|
94
115
|
|
|
95
116
|
/**
|