@lotics/ui 27.7.0 → 27.9.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 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 swaps to its input, the control inside goes `seamless`
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.
@@ -286,6 +286,11 @@ leaves the reader no reason to open the chevron.
286
286
 
287
287
  ## No dead rows — registers & tables
288
288
 
289
+ **The identifier leads.** A register's FIRST column is the thing the reader came to find the row
290
+ by — its key, code, or id — because that is what they are scanning against a document, an email,
291
+ or someone on the phone. Pushing it right of a name or a status makes every lookup a hunt across
292
+ the row, and no other column can take its place: a name repeats, a status is shared by dozens.
293
+
289
294
  Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
290
295
  (sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
291
296
  read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
@@ -770,6 +775,15 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
770
775
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
771
776
  hierarchy, not cramming.
772
777
 
778
+ **The trim test — does this element RESTATE something already on the screen?** Run it over every
779
+ screen before shipping: a per-row proportion bar when the percentage is already on the row; the
780
+ percentage itself when the column holds four values and a total; a state line that only says
781
+ nothing has happened; a gloss explaining the WIDGET rather than the domain. A gloss earns its
782
+ place only by stating a truth that is otherwise INVISIBLE. This is the hardest slop to see and the
783
+ most common, because it survives every other check: each piece is defensible alone, and only the
784
+ question "what does this tell me that the screen does not already say" catches decoration wearing
785
+ an information costume.
786
+
773
787
  ## Testing an overlay component — assert through the logic, prove in a browser
774
788
 
775
789
  **A `Popover`-backed surface does not mount under jsdom.** It positions in a `useLayoutEffect` +
@@ -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 swaps the input in **at the same height** (zero reflow, the whole
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
- input-swap editor — `InlineTextInput`, `InlineNumberInput`, `InlineTimePicker`,
81
- `InlineDatePicker` — opens edit mode immediately with the input focused; commit-on-blur then
82
- makes Tab itself the commit, so the chain is type → Tab → type with the next editor already
83
- open. Pointer focus never auto-opens (mousedown records "pointer" modality before focus fires
84
- `interaction_modality.ts`), so the click path is exactly what it always was. When a
85
- **keyboard** close (Enter/Escape) leaves focus on `<body>`, the editor returns focus to its
86
- resting view so the next Tab continues from the field. A **pointer** close (click ✓/✕, or
87
- click away onto non-focusable space) does NOT restore a mouse user has no next Tab to
88
- preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
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 **input-swap** editors (`InlineTextInput`,
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 view⇄edit toggle, draft buffer, and async save) — swap a custom
137
- input into that pair to join the family. The **popover** editors (`InlineSelect`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.7.0",
3
+ "version": "27.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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, setDraft] = useState<T>(value);
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
- setDraft(value);
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 resting value (a completed item that stays editable). */
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 resting value wraps to that many lines AND the editor opens
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, reveals an
38
- * edit affordance on hover, and swaps in-place to a text input on click at the
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={edit.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
- value={edit.draft}
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
  /**