@lotics/ui 16.0.0 → 16.2.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/src/checklist.tsx CHANGED
@@ -1,15 +1,28 @@
1
- import { createContext, useContext, type ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
1
+ import { createContext, useCallback, useContext, useId, useRef, useState, type ReactNode } from "react";
2
+ import { StyleSheet, View, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
3
3
  import { ActionMenu, type ActionMenuItem } from "./action_menu";
4
+ import { ActivityIndicator } from "./activity_indicator";
5
+ import { CheckboxInput } from "./checkbox_input";
6
+ import { colors } from "./colors";
7
+ import { CONTROL_RADIUS, CONTROL_TRANSITION } from "./control_surface";
8
+ import { FocusRingPressable } from "./focus_ring_pressable";
9
+ import { Icon } from "./icon";
10
+ import { IconButton } from "./icon_button";
11
+ import { useInlineEdit, useInlineEditFocusRestore } from "./inline_edit";
12
+ import { useLoticsLocale } from "./locale";
13
+ import { Text } from "./text";
14
+ import { TextInputField } from "./text_input_field";
4
15
 
5
16
  // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
6
- // (row height, control/title alignment, the meta/expansion indent, one
7
- // trailing column width) while the CONTENT stays composed: tasks (a
17
+ // (row height, control/title alignment, the note/meta/subtask/expansion
18
+ // indent, one trailing column width) while the CONTENT stays composed: tasks (a
8
19
  // `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
9
20
  // rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
10
21
  // readiness `meta` line and a fill editor in `expansion`) are the same
11
- // anatomy. `menu` carries the row's options (Delete lives BEHIND the menu
12
- // indirection that prevents
22
+ // anatomy. `note` is the row's own free-text second line (see
23
+ // {@link ChecklistRowNote}) and `subtasks` its collapsible CHILD steps (see
24
+ // {@link ChecklistRowSubtasks}). `menu` carries the row's ⋯ options (Delete lives
25
+ // BEHIND the menu — indirection that prevents
13
26
  // accidental destructive taps). Suggested tasks are NOT rows: offer the record
14
27
  // type's commons as `SuggestionChip`s under the list (tap = materialize,
15
28
  // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
@@ -19,6 +32,9 @@ import { ActionMenu, type ActionMenuItem } from "./action_menu";
19
32
  //
20
33
  // <Checklist trailingWidth={140}>
21
34
  // <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
35
+ // note={{ value: task.note, onSave: (v) => saveNote(task.id, v) }}
36
+ // subtasks={{ items: task.steps.map((s) => ({ key: s.id, label: s.label,
37
+ // checked: s.done, onToggle: (on) => toggleStep(s.id, on) })) }}
22
38
  // menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
23
39
  // accessibilityLabel: "Task options: …" }}>
24
40
  // <InlineTextInput variant="cell" struck={done} … />
@@ -39,8 +55,9 @@ export interface ChecklistProps {
39
55
  * once so the column lines up; omit when rows carry no trailing. */
40
56
  trailingWidth?: number;
41
57
  /** Width of every row's leading control — `CheckCircle` 20 (the default),
42
- * `CheckboxInput` 24. The `meta`/`expansion` indent derives from it, so
43
- * the second line always sits on the title's text edge. */
58
+ * `CheckboxInput` 24. The `note`/`meta`/`subtasks`/`expansion` indent
59
+ * derives from it, so every second line and child row sits on the title's
60
+ * text edge. */
44
61
  controlWidth?: number;
45
62
  children: ReactNode;
46
63
  }
@@ -54,20 +71,100 @@ export function Checklist(props: ChecklistProps) {
54
71
  );
55
72
  }
56
73
 
74
+ /** The row's own NOTE — the free-text second line a checklist item needs
75
+ * ("waiting on the signed copy") without promoting it to a record. One
76
+ * contract, so the muted line, the tap-to-edit, the removal, and the empty-row
77
+ * add affordance behave identically in every app. */
78
+ export interface ChecklistRowNote {
79
+ /** The note text. `""` = the row has NO note: the line is absent and a small
80
+ * add affordance sits on the row instead. */
81
+ value: string;
82
+ /** Persist the edited note. An EMPTY value REMOVES the note (`onSave("")`) —
83
+ * clearing the text IS the delete gesture, so the note needs no ⋯ entry of
84
+ * its own. Whitespace-only input is trimmed to `""` first. May be async: the
85
+ * line shows a saving spinner and surfaces a thrown error inline, staying
86
+ * open so the typing isn't lost. */
87
+ onSave: (next: string) => void | Promise<void>;
88
+ /** Accessible name (and tooltip) of the add affordance; defaults to the
89
+ * locale's `checklist.addNote`. Name the ROW when the list is long
90
+ * ("Add note: Book the carrier"). */
91
+ addLabel?: string;
92
+ /** Accessible name of the note line's press target; defaults to the locale's
93
+ * `checklist.editNote`, which WRAPS the note text so the line is announced
94
+ * with it ("Edit note: waiting on the signed copy"). */
95
+ editLabel?: string;
96
+ /** The editor's placeholder; defaults to the locale's
97
+ * `checklist.notePlaceholder`. */
98
+ placeholder?: string;
99
+ }
100
+
101
+ /** ONE child step under a checklist row: a checkbox and a short label, and
102
+ * deliberately NOTHING else — no note, no assignee, no ⋯ menu. A step that
103
+ * needs any of those is a TASK, so promote it to a row of its own rather than
104
+ * growing a second row anatomy inside the first. */
105
+ export interface ChecklistSubtask {
106
+ /** Stable identity — the React key, and what the caller patches on toggle. */
107
+ key: string;
108
+ /** The step's title AND its checkbox's accessible name. Keep it to a phrase:
109
+ * a child row has no second line to overflow into. */
110
+ label: string;
111
+ checked: boolean;
112
+ onToggle: (checked: boolean) => void;
113
+ }
114
+
115
+ /** The row's collapsible CHILD STEPS — the "3 things this one task is made of"
116
+ * that apps otherwise hand-roll as indented rows with a bespoke chevron. One
117
+ * contract, so the expander, the remaining-count, the indent, and the
118
+ * announcement behave identically in every app. */
119
+ export interface ChecklistRowSubtasks {
120
+ /** The child steps — see {@link ChecklistSubtask}. EMPTY renders nothing at
121
+ * all (not even the expander): an affordance that opens onto nothing is
122
+ * noise, so a row with no steps is indistinguishable from one that can't
123
+ * have them. */
124
+ items: ChecklistSubtask[];
125
+ /** CONTROLLED expansion. Omit it and the ROW owns the state: open on mount
126
+ * while ANY step is unchecked, closed when they're all done — and the
127
+ * user's toggle wins from then on, so checking the last step never yanks
128
+ * the list shut under the pointer. */
129
+ expanded?: boolean;
130
+ /** Fires with the NEXT state on every expander press — in BOTH modes, so an
131
+ * uncontrolled row can still persist "the user opened this one". */
132
+ onToggleExpanded?: (open: boolean) => void;
133
+ /** Accessible name of the expander, given the count of UNCHECKED steps;
134
+ * defaults to the locale's `checklist.subtasks`, which takes the same
135
+ * argument. Name the ROW when the list is long ("Steps · Book the carrier:
136
+ * 2 left"). The open/closed STATE is NOT its job — `aria-expanded` carries
137
+ * that, and a name that flips on toggle re-announces the whole control. */
138
+ countLabel?: (missing: number) => string;
139
+ }
140
+
57
141
  interface ChecklistRowBaseProps {
58
142
  /** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
59
143
  control: ReactNode;
60
144
  /** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
61
145
  children: ReactNode;
146
+ /** The row's free-text second line — see {@link ChecklistRowNote}. It sits
147
+ * directly under the title (above `meta`) on the title's text edge, ALWAYS
148
+ * visible — never behind `expansion`, because a note the reader must open to
149
+ * see gets written twice. Composes with `trailing` AND `meta`: the note is a
150
+ * second LINE, not a replacement for either. */
151
+ note?: ChecklistRowNote;
62
152
  /** The row's ⋯ options menu (destructive items last, `danger: true`) —
63
153
  * Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
64
154
  * destroy a task. Name the task in the label ("Task options: Book the
65
155
  * carrier"). The ⋯ column aligns only when every row in the list carries
66
156
  * a menu — keep its presence uniform per list. */
67
157
  menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
68
- /** A BLOCK slot under the row (below `meta`), indented to the title's text
69
- * edge a transient inline fill editor, a drill-down. Render it only
70
- * while open; the list's geometry (the indent) is owned here. */
158
+ /** The row's collapsible CHILD STEPS see {@link ChecklistRowSubtasks}. The
159
+ * parent grows a chevron expander (with the unchecked count beside it) on
160
+ * the TRAILING side of the title, never a leading one: the leading slot is
161
+ * `control`, and a chevron there would push the title off the text edge on
162
+ * the rows that have no steps. */
163
+ subtasks?: ChecklistRowSubtasks;
164
+ /** A BLOCK slot under the row (below `meta` and the open `subtasks`),
165
+ * indented to the title's text edge — a transient inline fill editor, a
166
+ * drill-down. Render it only while open; the list's geometry (the indent)
167
+ * is owned here. */
71
168
  expansion?: ReactNode;
72
169
  }
73
170
 
@@ -88,23 +185,166 @@ export type ChecklistRowProps = ChecklistRowBaseProps &
88
185
  }
89
186
  );
90
187
 
91
- /** One checklist line: control · title (flex) · the aligned trailing cell · ⋯,
92
- * with an optional indented `meta` line below for narrow surfaces and an
93
- * optional `expansion` block under that (both sit on the title's text edge). */
188
+ /** One checklist line: control · title (flex) · the subtask expander · the note
189
+ * affordance · the aligned trailing cell · ⋯, with the optional `note` line
190
+ * below it, an optional indented `meta` line for narrow surfaces, the expanded
191
+ * `subtasks`, and an optional `expansion` block under that (all of them sit on
192
+ * the title's text edge). */
94
193
  export function ChecklistRow(props: ChecklistRowProps) {
95
- const { control, children, trailing, menu, meta, expansion } = props;
194
+ const { control, children, trailing, menu, meta, note, subtasks, expansion } = props;
96
195
  const { trailingWidth, controlWidth } = useContext(ChecklistContext);
97
- // Indent past the control + the row gap so meta/expansion align with the title.
196
+ const labels = useLoticsLocale().checklist;
197
+ // The title names the subtask GROUP (`aria-labelledby`), so a screen reader
198
+ // announces the children under the parent they belong to instead of as a run
199
+ // of loose checkboxes.
200
+ const titleId = useId();
201
+ const subtaskItems = subtasks?.items;
202
+ const missing = subtaskItems == null ? 0 : subtaskItems.reduce((n, item) => (item.checked ? n : n + 1), 0);
203
+ // Uncontrolled expansion is seeded ONCE (lazily): a row with work left opens,
204
+ // and the user owns it from then on. Re-deriving it every render would slam
205
+ // the panel shut the instant the last step is checked — mid-interaction.
206
+ const [selfExpanded, setSelfExpanded] = useState(() => subtaskItems?.some((item) => !item.checked) ?? false);
207
+ const expanded = subtasks?.expanded ?? selfExpanded;
208
+ const toggleSubtasks = useCallback(() => {
209
+ const next = !expanded;
210
+ setSelfExpanded(next);
211
+ subtasks?.onToggleExpanded?.(next);
212
+ }, [expanded, subtasks]);
213
+ // ONE editor per row, opened by either note affordance (the add button on an
214
+ // empty row, or a press on the note line itself). The optional call is
215
+ // unreachable without a `note` — nothing that opens the editor renders then.
216
+ const noteEdit = useInlineEdit<string>({ value: note?.value ?? "", onSave: (next) => note?.onSave(next) });
217
+ // Whichever resting affordance is mounted (the line, or the add button once
218
+ // the note is removed) takes focus back when a keyboard close drops it.
219
+ const noteRestRef = useRef<View>(null);
220
+ useInlineEditFocusRestore(
221
+ noteEdit.editing,
222
+ useCallback(() => noteRestRef.current?.focus(), []),
223
+ );
224
+ // A note is trimmed on the way out, so trailing whitespace never fakes a note
225
+ // and a whitespace-only entry lands as the removal (`""`).
226
+ const commitNote = useCallback(() => void noteEdit.commit(noteEdit.draft.trim()), [noteEdit]);
227
+ const onNoteKeyPress = useCallback(
228
+ (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
229
+ const key = e.nativeEvent.key;
230
+ if (key === "Escape") noteEdit.cancel();
231
+ else if (key === "Enter") commitNote();
232
+ },
233
+ [noteEdit, commitNote],
234
+ );
235
+ const notePlaceholder = note?.placeholder ?? labels.notePlaceholder;
236
+ // Indent past the control + the row gap so note/meta/expansion align with the title.
98
237
  const indent = controlWidth + 12;
99
238
  return (
100
239
  <View>
101
240
  <View style={styles.row}>
102
241
  {control}
103
- <View style={styles.title}>{children}</View>
242
+ <View style={styles.title} nativeID={titleId}>
243
+ {children}
244
+ </View>
245
+ {/* The DISCLOSURE: one stable name + `aria-expanded` for the state (a
246
+ name that flips on toggle re-announces the whole control), and the
247
+ remaining count beside the chevron — a collapsed row's only signal
248
+ that work hangs below it. */}
249
+ {subtaskItems != null && subtaskItems.length > 0 ? (
250
+ <FocusRingPressable
251
+ onPress={toggleSubtasks}
252
+ accessibilityRole="button"
253
+ accessibilityLabel={subtasks?.countLabel?.(missing) ?? labels.subtasks(missing)}
254
+ aria-expanded={expanded}
255
+ userSelect="none"
256
+ style={(state) => [styles.expander, CONTROL_TRANSITION, state.hovered ? styles.expanderHovered : null]}
257
+ >
258
+ <Icon name={expanded ? "chevron-down" : "chevron-right"} size={14} color={colors.zinc[400]} />
259
+ {missing > 0 ? (
260
+ <Text size="xs" color="muted" tabular>
261
+ {missing}
262
+ </Text>
263
+ ) : null}
264
+ </FocusRingPressable>
265
+ ) : null}
266
+ {/* The add affordance stands in for the missing line — always visible
267
+ (not hover-only: a hidden note affordance is undiscoverable, and
268
+ unreachable by touch), quiet enough to stay out of the row's scan. */}
269
+ {note != null && note.value === "" && !noteEdit.editing ? (
270
+ <IconButton
271
+ ref={noteRestRef}
272
+ icon="sticky-note"
273
+ size="sm"
274
+ iconColor={colors.zinc[400]}
275
+ // The tooltip IS the accessible name (`IconButton` falls back to it).
276
+ tooltip={note.addLabel ?? labels.addNote}
277
+ onPress={noteEdit.begin}
278
+ />
279
+ ) : null}
104
280
  {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
105
281
  {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
106
282
  </View>
283
+ {note != null && (noteEdit.editing || note.value !== "") ? (
284
+ <View style={[styles.note, { paddingLeft: indent }]}>
285
+ {noteEdit.editing ? (
286
+ <View>
287
+ <View style={styles.noteEditor}>
288
+ <TextInputField
289
+ value={noteEdit.draft}
290
+ onChangeText={noteEdit.setDraft}
291
+ onBlur={commitNote}
292
+ onKeyPress={onNoteKeyPress}
293
+ autoFocus
294
+ placeholder={notePlaceholder}
295
+ accessibilityLabel={notePlaceholder}
296
+ style={styles.noteInput}
297
+ />
298
+ {/* The spinner floats inside the field's right edge — an async
299
+ save must never resize the line. */}
300
+ {noteEdit.saving ? (
301
+ <View style={styles.noteSaving} pointerEvents="none">
302
+ <ActivityIndicator size={14} color={colors.zinc[400]} />
303
+ </View>
304
+ ) : null}
305
+ </View>
306
+ {noteEdit.error ? (
307
+ <Text size="xs" color="danger" style={styles.noteError}>
308
+ {noteEdit.error}
309
+ </Text>
310
+ ) : null}
311
+ </View>
312
+ ) : (
313
+ <FocusRingPressable
314
+ ref={noteRestRef}
315
+ onPress={noteEdit.begin}
316
+ accessibilityRole="button"
317
+ accessibilityLabel={note.editLabel ?? labels.editNote(note.value)}
318
+ userSelect="none"
319
+ // An editable VALUE, not a button: the arrow cursor stays and the
320
+ // hover is the dense-cell wash (the `InlineEditView` language).
321
+ style={(state) => [styles.noteLine, CONTROL_TRANSITION, state.hovered ? styles.noteLineHovered : null]}
322
+ >
323
+ <Text size="xs" color="muted">
324
+ {note.value}
325
+ </Text>
326
+ </FocusRingPressable>
327
+ )}
328
+ </View>
329
+ ) : null}
107
330
  {meta != null ? <View style={[styles.meta, { paddingLeft: indent }]}>{meta}</View> : null}
331
+ {subtaskItems != null && subtaskItems.length > 0 && expanded ? (
332
+ <View style={[styles.subtasks, { paddingLeft: indent }]} role="group" aria-labelledby={titleId}>
333
+ {subtaskItems.map((item) => (
334
+ <View key={item.key} style={styles.subtask}>
335
+ <CheckboxInput checked={item.checked} onChange={item.onToggle} accessibilityLabel={item.label} />
336
+ <Text
337
+ size="sm"
338
+ color={item.checked ? "muted" : "default"}
339
+ decoration={item.checked ? "lineThrough" : undefined}
340
+ style={styles.subtaskLabel}
341
+ >
342
+ {item.label}
343
+ </Text>
344
+ </View>
345
+ ))}
346
+ </View>
347
+ ) : null}
108
348
  {expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
109
349
  </View>
110
350
  );
@@ -114,6 +354,24 @@ const styles = StyleSheet.create({
114
354
  list: { gap: 4 },
115
355
  row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
116
356
  title: { flex: 1 },
357
+ note: { paddingBottom: 2 },
358
+ // Resting and editing occupy the SAME 28px band on the title's text inset,
359
+ // so opening the note never nudges the rows below it.
360
+ noteLine: { alignSelf: "flex-start", minHeight: 28, justifyContent: "center", borderRadius: CONTROL_RADIUS, paddingHorizontal: 8, paddingVertical: 4, cursor: "auto" },
361
+ noteLineHovered: { backgroundColor: colors.zinc[100] },
362
+ noteEditor: { position: "relative" },
363
+ noteInput: { borderWidth: 0, backgroundColor: "transparent", height: 28, paddingVertical: 2, paddingHorizontal: 8 },
364
+ noteSaving: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
365
+ noteError: { paddingHorizontal: 8 },
366
+ // The expander rides the row's 24px affordance band beside the note button —
367
+ // never the leading edge, which belongs to `control`.
368
+ expander: { flexDirection: "row", alignItems: "center", gap: 2, height: 24, paddingHorizontal: 4, borderRadius: CONTROL_RADIUS },
369
+ expanderHovered: { backgroundColor: colors.zinc[100] },
117
370
  meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
371
+ // Child rows are DENSER than the parent (24 vs 32): the indent alone doesn't
372
+ // read as subordinate on a long list — the rhythm has to say it too.
373
+ subtasks: { paddingTop: 2, paddingBottom: 4, gap: 2 },
374
+ subtask: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 24 },
375
+ subtaskLabel: { flex: 1 },
118
376
  expansion: { paddingTop: 2, paddingBottom: 6 },
119
377
  });
@@ -48,6 +48,10 @@ export interface DatePickerLabels extends SegmentLabels {
48
48
  startTime: string;
49
49
  /** Accessible name for the end-time field (datetime range). */
50
50
  endTime: string;
51
+ /** Accessible name for the start-date segment group (range formats). */
52
+ startDate: string;
53
+ /** Accessible name for the end-date segment group (range formats). */
54
+ endDate: string;
51
55
  /** Footer action: commit and close the popover. */
52
56
  done: string;
53
57
  /** Inline error when a typed date is left incomplete/invalid (never committed). */
@@ -79,6 +83,11 @@ export interface DatePickerProps {
79
83
  locale?: string;
80
84
  /** Shown in the field when nothing is selected yet. */
81
85
  placeholder?: string;
86
+ /** Accessible name for the field's segment group (both groups on a range,
87
+ * suffixed with the start/end names). `FormDatePicker` defaults it to its own
88
+ * visible label; a bare `DatePicker` must be given one — a visible label
89
+ * beside the field is not programmatically associated. */
90
+ accessibilityLabel?: string;
82
91
  }
83
92
 
84
93
  // =============================================================================
@@ -328,6 +337,7 @@ export function DatePicker(props: DatePickerProps) {
328
337
  labels,
329
338
  locale,
330
339
  placeholder,
340
+ accessibilityLabel,
331
341
  } = props;
332
342
 
333
343
  const isRange = isRangeFormat(format);
@@ -385,10 +395,21 @@ export function DatePicker(props: DatePickerProps) {
385
395
  <Icon name="calendar" size={20} color={disabled ? colors.zinc["300"] : colors.zinc["400"]} />
386
396
  );
387
397
 
398
+ // Each segment group's accessible name. A range names both halves even without
399
+ // a field label — an unnamed pair of identical-looking groups is unnavigable.
400
+ const partLabels = isRange
401
+ ? ([mergedLabels.startDate, mergedLabels.endDate].map((part) =>
402
+ accessibilityLabel ? `${accessibilityLabel} — ${part}` : part,
403
+ ) as string[])
404
+ : accessibilityLabel
405
+ ? [accessibilityLabel]
406
+ : undefined;
407
+
388
408
  const trigger = (
389
409
  <DateField
390
410
  triggerRef={triggerRef}
391
411
  testID={testID}
412
+ partLabels={partLabels}
392
413
  parts={isRange ? halves : [value ?? ""]}
393
414
  onPartChange={isRange ? setHalf : (_, next) => onValueChange(next)}
394
415
  hasTime={hasTime}
@@ -155,7 +155,11 @@ export function DetailRow(props: DetailRowProps) {
155
155
  const flatTuck = flat && annotated ? -Math.max(0, Math.round((minHeight - getInputLineHeight(false)) / 2)) : 0;
156
156
  const labelCell = (cellStyle: StyleProp<ViewStyle>) => (
157
157
  <View style={[styles.labelCell, { minHeight }, cellStyle]}>
158
- <Text size={labelSize} color="muted" numberOfLines={1}>
158
+ {/* The label WRAPS inside its column — it is NEVER clipped. A field name
159
+ the reader can't finish is worse than a taller row, and a fixed
160
+ `labelWidth` guarantees the clip on every long name ("Registered
161
+ business address"), not just the odd one. */}
162
+ <Text size={labelSize} color="muted">
159
163
  {label}
160
164
  </Text>
161
165
  </View>
@@ -191,9 +195,9 @@ export function DetailRow(props: DetailRowProps) {
191
195
  // inline editor self-persists — only the look converges.
192
196
  return (
193
197
  <View style={styles.stackedRow}>
194
- <Text weight="medium" numberOfLines={1}>
195
- {label}
196
- </Text>
198
+ {/* Wraps, like the horizontal label — a stacked row has the FULL width
199
+ to spend, so clipping here would be gratuitous. */}
200
+ <Text weight="medium">{label}</Text>
197
201
  {/* stacked wears the FORM grammar exactly: label · description ·
198
202
  control · warning · error (the `FormField` order) */}
199
203
  {description != null ? <Text color="muted">{description}</Text> : null}
@@ -259,6 +263,12 @@ const styles = StyleSheet.create({
259
263
  gap: 12,
260
264
  },
261
265
  flexLabel: { flex: 1 },
266
+ // `center` is what makes a WRAPPED label align sanely, and it does both jobs
267
+ // with no measurement: a one-line label is shorter than the control band, so
268
+ // it centers on the control line (the alignment law); a wrapped label is as
269
+ // tall as the band or taller, so it fills it and its FIRST line tops out
270
+ // level with the value's first control line. Do not "fix" this to
271
+ // `flex-start` — that drops every single-line label to the band's top edge.
262
272
  labelCell: { justifyContent: "center" },
263
273
  // The first line of the value cell — the control centers in it exactly like
264
274
  // the label and trailing cells do.
@@ -19,7 +19,9 @@ export function FormDatePicker(props: FormDatePickerProps) {
19
19
  optional={optional}
20
20
  optionalLabel={optionalLabel}
21
21
  >
22
- <DatePicker {...datePickerProps} />
22
+ {/* The visible FormField label is not programmatically associated with the
23
+ segment inputs, so it doubles as the accessible name unless overridden. */}
24
+ <DatePicker accessibilityLabel={label} {...datePickerProps} />
23
25
  </FormField>
24
26
  );
25
27
  }
@@ -75,8 +75,11 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
75
75
  justifyContent: "space-between",
76
76
  }}
77
77
  >
78
+ {/* The label WRAPS (same law as DetailRow) — a clamp plus a fixed row
79
+ width guaranteed the clip. The optional marker keeps its single
80
+ line and never shrinks; the label yields. */}
78
81
  {!!label && (
79
- <Text nativeID={labelId} numberOfLines={1} weight="medium">
82
+ <Text nativeID={labelId} weight="medium" style={{ flexShrink: 1 }}>
80
83
  {label}
81
84
  </Text>
82
85
  )}
package/src/icon.tsx CHANGED
@@ -176,6 +176,7 @@ import Square from "lucide-react-native/dist/esm/icons/square";
176
176
  import SquareCheck from "lucide-react-native/dist/esm/icons/square-check";
177
177
  import SquarePen from "lucide-react-native/dist/esm/icons/square-pen";
178
178
  import SquareSigma from "lucide-react-native/dist/esm/icons/square-sigma";
179
+ import StickyNote from "lucide-react-native/dist/esm/icons/sticky-note";
179
180
  import Table from "lucide-react-native/dist/esm/icons/table";
180
181
  import Table2 from "lucide-react-native/dist/esm/icons/table-2";
181
182
  import Tag from "lucide-react-native/dist/esm/icons/tag";
@@ -372,6 +373,7 @@ const iconComponents = {
372
373
  "square-check": SquareCheck,
373
374
  "square-pen": SquarePen,
374
375
  "square-sigma": SquareSigma,
376
+ "sticky-note": StickyNote,
375
377
  table: Table,
376
378
  "table-2": Table2,
377
379
  tag: Tag,
@@ -4,8 +4,9 @@ import { colors } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
5
  import { type ButtonColor } from "./button";
6
6
  import { getButtonIconColor } from "./button_colors";
7
- import { Ref, useCallback } from "react";
7
+ import { Ref } from "react";
8
8
  import { TooltipSide } from "./tooltip";
9
+ import { useGatedPress } from "./use_gated_press";
9
10
 
10
11
  interface IconButtonBase {
11
12
  ref?: Ref<View>;
@@ -61,14 +62,12 @@ export function IconButton(props: IconButtonProps) {
61
62
  style,
62
63
  } = props;
63
64
 
64
- const handlePress = useCallback(
65
- (event: GestureResponderEvent) => {
66
- onPress?.(event);
67
- },
68
- [onPress],
69
- );
65
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
66
+ // and reports busy while it does — `busy` drives the spinner + the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<GestureResponderEvent>(onPress);
68
+ const busy = loading || waiting;
70
69
 
71
- const disabledOrLoading = disabled || loading;
70
+ const disabledOrLoading = disabled || busy;
72
71
  const tint =
73
72
  iconColor ??
74
73
  (color === "none" ? colors.zinc[700] : color === "white" ? colors.white : getButtonIconColor(color));
@@ -88,7 +87,7 @@ export function IconButton(props: IconButtonProps) {
88
87
  // without shifting surrounding layouts.
89
88
  hitSlop={size === "lg" ? 0 : size === "sm" ? 8 : 6}
90
89
  >
91
- {loading ? (
90
+ {busy ? (
92
91
  <ActivityIndicator size="small" color={tint} />
93
92
  ) : (
94
93
  <Icon size={size === "sm" ? 14 : size === "lg" ? 20 : 18} name={icon} color={tint} />
@@ -10,6 +10,7 @@ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./
10
10
  import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
11
11
  import { getInteractionModality } from "./interaction_modality";
12
12
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
+ import { trackCommit } from "./pending_commits";
13
14
 
14
15
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
15
16
  * The view box matches it — same height, padding, and a 1px transparent border
@@ -69,7 +70,11 @@ export function useInlineEdit<T>(opts: {
69
70
  setSaving(true);
70
71
  setError(null);
71
72
  try {
72
- await onSave(candidate);
73
+ // Registered so an action pressed in the SAME gesture (the press that caused
74
+ // this blur) waits for the write instead of reading the pre-edit record —
75
+ // see pending_commits.ts. The IIFE starts `onSave` synchronously, so the
76
+ // write still leaves on mousedown, and turns a sync throw into a rejection.
77
+ await trackCommit((async () => onSave(candidate))());
73
78
  setEditing(false);
74
79
  } catch (e) {
75
80
  // Stay in edit mode so the entry isn't lost — show the error, re-arm.
@@ -85,6 +90,34 @@ export function useInlineEdit<T>(opts: {
85
90
  return { editing, draft, setDraft, saving, error, begin, cancel, commit };
86
91
  }
87
92
 
93
+ /**
94
+ * Keyboard TAB-ORDER continuity for any inline editor: when edit mode closes
95
+ * while its input still holds focus (Enter, Escape, a ✓/✕), the unmount drops
96
+ * focus to `<body>` and the next Tab would restart from the top of the page —
97
+ * so `restore` is called to put focus back on the RESTING control.
98
+ *
99
+ * Gated on KEYBOARD modality: a POINTER close also drops focus to `<body>`, but
100
+ * restoring there both steals focus from a mouse user AND — because a bare
101
+ * `.focus()` scrolls the target into view — jumps the scroll position. A mouse
102
+ * user has no "next Tab" to preserve. A Tab-away blur-commit leaves focus on the
103
+ * next field (not `<body>`), so this never steals focus back either.
104
+ */
105
+ export function useInlineEditFocusRestore(editing: boolean, restore: () => void) {
106
+ const wasEditing = useRef(editing);
107
+ useEffect(() => {
108
+ if (
109
+ wasEditing.current &&
110
+ !editing &&
111
+ typeof document !== "undefined" &&
112
+ document.activeElement === document.body &&
113
+ shouldRestoreFocusOnClose(getInteractionModality())
114
+ ) {
115
+ restore();
116
+ }
117
+ wasEditing.current = editing;
118
+ }, [editing, restore]);
119
+ }
120
+
88
121
  /** Which surface an inline editor lives on — the ONE axis that separates a FORM
89
122
  * field from a data-grid CELL:
90
123
  * - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
@@ -238,7 +271,6 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
238
271
 
239
272
  const viewRef = useRef<View>(null);
240
273
  const suppressedAt = useRef<number | null>(null);
241
- const wasEditing = useRef(editing);
242
274
 
243
275
  // Keyboard focus opens edit mode immediately; the input's own `autoFocus`
244
276
  // then moves focus into it. Pointer focus never lands here (mousedown records
@@ -250,32 +282,15 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
250
282
  onBegin();
251
283
  }, [disabled, onBegin]);
252
284
 
253
- // When edit mode closes while its input (or a ✓/✕ button) still holds focus —
254
- // Enter, Escape, the buttons the unmount drops focus to <body> and the next
255
- // Tab would restart from the top of the page. Return focus to the view button,
256
- // arming the suppression window so the programmatic focus doesn't re-open the
257
- // editor it just closed. A Tab-away blur-commit leaves focus on the next field
258
- // (not <body>), so this never steals focus back.
259
- //
260
- // Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
261
- // continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
262
- // space) also drops focus to <body>, but restoring it there both steals focus
263
- // from a mouse user AND — because a bare `.focus()` scrolls the target into
264
- // view — jumps the scroll position (often to the top of the page). A mouse
265
- // user has no "next Tab" to preserve, so skip the restore for pointer closes.
266
- useEffect(() => {
267
- if (
268
- wasEditing.current &&
269
- !editing &&
270
- typeof document !== "undefined" &&
271
- document.activeElement === document.body &&
272
- shouldRestoreFocusOnClose(getInteractionModality())
273
- ) {
285
+ // Focus returns to the view button on a keyboard close, arming the suppression
286
+ // window so the programmatic focus doesn't re-open the editor it just closed.
287
+ useInlineEditFocusRestore(
288
+ editing,
289
+ useCallback(() => {
274
290
  suppressedAt.current = Date.now();
275
291
  viewRef.current?.focus();
276
- }
277
- wasEditing.current = editing;
278
- }, [editing]);
292
+ }, []),
293
+ );
279
294
 
280
295
  if (!editing) {
281
296
  return (