@lotics/ui 16.1.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/docs/catalog.md CHANGED
@@ -571,7 +571,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
571
571
  select.
572
572
  - **`form_date_picker`** — `FormDatePicker`: `FormField` wrapping a `DatePicker` — one
573
573
  labeled date field. (Omits `style`; for a grid cell use a bare `FormField style={half}`
574
- around `DatePicker`.)
574
+ around `DatePicker`.) Defaults the picker's `accessibilityLabel` to its own visible label
575
+ — the FormField label text is not otherwise associated with the segment inputs. A bare
576
+ `DatePicker` outside a FormField must be given `accessibilityLabel` explicitly; range
577
+ formats suffix each half with the locale's start/end-date names.
575
578
  - **`switch_button`** — `SwitchButton`: the full-ROW toggle — a `PressableHighlight` row
576
579
  (optional icon + medium title left, `Switch` pinned right) where the whole row IS the
577
580
  switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
@@ -605,7 +608,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
605
608
  ### Inline editing & record surfaces
606
609
 
607
610
  - **`inline_edit`** — `useInlineEdit` + `InlineEditView` / `InlineEditFrame` +
608
- `INLINE_CONTROL_HEIGHT` (40) + `inlineValueTextStyle`: the engine custom inline editors
611
+ `useInlineEditFocusRestore` (returns focus to the RESTING control when a KEYBOARD close drops
612
+ it to `<body>` — what an editor with its own resting shape, e.g. the checklist `note` line,
613
+ reaches for instead of the frame) + `INLINE_CONTROL_HEIGHT` (40) +
614
+ `inlineValueTextStyle`: the engine custom inline editors
609
615
  join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
610
616
  INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
611
617
  `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
@@ -657,7 +663,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
657
663
  label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column
658
664
  FILLS the row so a stack of inline editors all span the same width + none jumps wider on
659
665
  edit; optional `trailing` slot renders a right-side action/badge after the value (units
660
- belong IN the value via `InlineNumberInput format`). The FIELD-ANNOTATION vocabulary (same
666
+ belong IN the value via `InlineNumberInput format`). The LABEL WRAPS inside its column and
667
+ is never clipped — a fixed `labelWidth` would otherwise ellipsize every long field name
668
+ ("Registered business address"), and a name the reader can't finish is worse than a taller
669
+ row; a wrapped label's FIRST line stays level with the value's first control line while a
670
+ one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
661
671
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
662
672
  **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
663
673
  mirrors the form order label · description · control); **`warning`** = a consequence to weigh
@@ -741,8 +751,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
741
751
  filled check when done, distinct from the square checkbox; the task/to-do/checklist
742
752
  toggle.
743
753
  - **`checklist`** — `Checklist` + `ChecklistRow` — the record-scoped checklist COMPOUND: it
744
- owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `meta`/
745
- `expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
754
+ owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `note`/`meta`/
755
+ `subtasks`/`expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
746
756
  stays composed — `control` takes the `CheckCircle` (omit onChange = read-only ring; a
747
757
  PICKER list puts a `CheckboxInput` here and sets `controlWidth={24}` so the indent
748
758
  tracks), children the struck transparent `InlineTextInput` or plain `Text`, `trailing` an
@@ -751,8 +761,34 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
751
761
  danger-styled and last, never a bare ✕ (omit on read-only rows). NARROW surfaces (a
752
762
  drawer/peek checklist) put the editors on the `meta` line instead of `trailing` — the
753
763
  second line indents to the title's text edge so the TITLE keeps the full width; wide
754
- surfaces use `trailing`; never both. `expansion` is a BLOCK slot under the row on the
755
- same indent — a transient inline fill editor, a drill-down rendered only while open
764
+ surfaces use `trailing`; never both. **`note` is the row's own free-text line**
765
+ `{value, onSave, addLabel?, editLabel?, placeholder?}` (`ChecklistRowNote`): the note
766
+ renders as a muted line directly under the title (above `meta`, on its text edge) and is
767
+ ALWAYS visible — never behind `expansion` — presses to edit in place, and saving EMPTY
768
+ removes it (`onSave("")`, whitespace trimmed first — clearing IS the delete, so a note
769
+ needs no ⋯ entry). A row with no note shows a small `sticky-note` add button instead,
770
+ which opens the focused empty editor. It composes with `trailing` AND `meta` (a second
771
+ LINE, not a replacement), the save may be async (spinner + inline error, editor stays
772
+ open), and chrome comes from the `checklist` locale slice (`addNote`, `editNote(note)` —
773
+ it wraps the text so the line announces with it — `notePlaceholder`). Never hand-roll a
774
+ muted sub-line + editor on a checklist row. **`subtasks` are the row's collapsible CHILD
775
+ STEPS** — `{items, expanded?, onToggleExpanded?, countLabel?}` (`ChecklistRowSubtasks`),
776
+ each item a `ChecklistSubtask` `{key, label, checked, onToggle}` and nothing more (no
777
+ note, no assignee, no ⋯: a step needing those is a TASK — give it a row). The parent grows
778
+ a chevron expander on the TRAILING side of the title (never the leading edge — that's
779
+ `control`, and a chevron there would knock the title off the text edge on every row
780
+ WITHOUT steps) carrying the count of UNCHECKED steps beside it; expanded, the children
781
+ render as compact `CheckboxInput` + label rows on the title's text edge, denser than the
782
+ parent (24 vs 32) and struck+muted when checked. `items: []` renders NOTHING, not even the
783
+ expander, so a row can pass the prop unconditionally. Expansion is controlled with
784
+ `expanded` + `onToggleExpanded`, or UNCONTROLLED (omit `expanded`): open on mount while any
785
+ step is unchecked, then the user's toggle wins — checking the last step must not yank the
786
+ list shut. A11y: the expander keeps ONE stable name (`countLabel(missing)` → the
787
+ `checklist` locale slice's `subtasks(missing)` → English) with `aria-expanded` carrying the
788
+ state, and the children sit in a `role="group"` named by the row's own title, so they are
789
+ announced UNDER their parent instead of as loose checkboxes. Never hand-roll indented child
790
+ rows with a bespoke chevron. `expansion` is a BLOCK slot under the row on
791
+ the same indent — a transient inline fill editor, a drill-down — rendered only while open
756
792
  (`tpl_record`'s Document set rows are the worked example). SUGGESTIONS are never rows:
757
793
  offer the commons as `SuggestionChip`s under the list. Close the list with `CaptureRow`.
758
794
  There is deliberately NO monolithic Task component — richer task-management rows compose
@@ -440,7 +440,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
440
440
  fact or consequence, never widget mechanics, never a second sentence; `warning` marks a
441
441
  consequence, not decoration. Rows share ONE
442
442
  alignment law: the row top-aligns and label · control · trailing each center within the
443
- first control line — a tall value block never drags the label. A FLAT value row
443
+ first control line — a tall value block never drags the label, and a LABEL too long for its
444
+ column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
445
+ the value. A FLAT value row
444
446
  (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
445
447
  control band's slack so the perceived gap under the text equals the gap under a chip —
446
448
  the band's invisible bottom half must never read as a hole above the description.
@@ -157,7 +157,9 @@ narrower than its neighbours'. Rows share ONE alignment law: the row top-aligns
157
157
  control · trailing each center within the first control line — annotations (the three tones
158
158
  under the value: `description` muted · `warning` amber · `error` danger, each indented to the
159
159
  chip's 8px text inset) grow the row downward without dragging the label. The full annotation
160
- vocabulary lives in composition.md §Field annotations.
160
+ vocabulary lives in composition.md §Field annotations. A long LABEL wraps inside its column
161
+ instead of clipping — never shorten a field name to fit the label width, and never reach for a
162
+ tooltip to recover the tail; the wrapped label's first line stays level with the value.
161
163
 
162
164
  ### The editability affordance
163
165
 
@@ -261,7 +263,19 @@ struck transparent `InlineTextInput`, or plain `Text`), then EITHER a `trailing`
261
263
  surfaces (the per-task assignee `InlineMemberSelect`, sized by the list's `trailingWidth`) OR a
262
264
  `meta` line under the title on NARROW surfaces (a drawer/popover checklist — the two are mutually
263
265
  exclusive), plus a `menu` for the row's ⋯ options — never a decorative progress strip. `CaptureRow`
264
- closes the list as its add-affordance. A NEW record starts with an EMPTY checklist tasks truly
266
+ closes the list as its add-affordance. A task's own free text the "why it's stuck" nothing else on
267
+ the record holds — is the row's **`note`** (`{value, onSave}`): a muted line under the title, ALWAYS
268
+ visible (a note behind an expansion gets written twice), pressed to edit in place, and REMOVED by
269
+ saving it empty — so it needs no ⋯ entry and no second field. A note-less row carries a small add
270
+ button that opens the focused editor. It composes with `trailing` AND `meta` — a second LINE, not a
271
+ replacement — so a per-row note is never a hand-rolled sub-line, a comment thread, or an
272
+ expansion. A task made of several ticks takes **`subtasks`** (`{items, expanded?,
273
+ onToggleExpanded?, countLabel?}`) rather than N sibling rows: the parent grows a chevron + the
274
+ count of unchecked steps, and the children — `{key, label, checked, onToggle}` and NOTHING else —
275
+ render indented to the title's text edge when open. The line is sharp: a step that needs its own
276
+ note, due date, assignee, or ⋯ menu is a TASK, so give it a row; subtasks are for the ticks that
277
+ only make sense under their parent. Omit `expanded` and the row opens itself while work is left,
278
+ then hands the state to the user. A NEW record starts with an EMPTY checklist — tasks truly
265
279
  vary. The commons split in two: MANDATORY tasks are seeded by the app (a workflow on create, per
266
280
  record type) — no human types them; common-but-OPTIONAL tasks appear as `SuggestionChip`s under
267
281
  the list — a PILL, never a row, so a suggestion can't be mistaken for a task (tap = materialize,
package/docs/templates.md CHANGED
@@ -292,7 +292,12 @@ billing, and quick-capture templates. Top → bottom:
292
292
  = `dueTone` — red past due, amber ≤3d, else muted), the assignee an **`avatarOnly`** bare avatar
293
293
  (dashed add-ghost when unset), sized to the list's `trailingWidth` so the title never overlaps.
294
294
  (Contrast the General section's dates/assignee, the same editors in the default `variant="form"`
295
- — a zinc-50 chip + hover-border + the full member chip.)
295
+ — a zinc-50 chip + hover-border + the full member chip.) Two rows carry a **`note`** — the kit's
296
+ per-row muted second line (press to edit, save empty to remove); the rest show its add button.
297
+ ONE row ("Book the carrier") carries **`subtasks`** — four child ticks behind the kit's chevron +
298
+ remaining-count expander, open because work is left; every other row passes an EMPTY list, which
299
+ renders nothing at all. It is the line to copy: a booking's steps carry only a tick, so they stay
300
+ child rows — anything needing its own note/due/assignee would be a task instead.
296
301
  - **Documents** — the Agents "Document desk" pattern (this template is its worked example —
297
302
  see the Agents chapter below): the register `Table` (search · Add files) whose selection
298
303
  feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
@@ -165,6 +165,16 @@ interface StageTask {
165
165
  /** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
166
166
  * no calendar glyph, coloured by urgency (`tone`), press to edit. */
167
167
  due: string;
168
+ /** The task's own note (`""` = none). The `ChecklistRow` `note` contract owns
169
+ * the whole affordance — the muted second line, the tap-to-edit, the add
170
+ * button on a note-less row, and removal by saving empty. */
171
+ note: string;
172
+ /** The task's CHILD STEPS (`[]` = none, and then nothing renders — not even
173
+ * the expander). The `ChecklistRow` `subtasks` contract owns the whole
174
+ * affordance: the chevron + remaining count on the parent, the open/closed
175
+ * state, and the indented child rows. A step that needs its own note, due
176
+ * date, or assignee is a TASK — give it a row, not a subtask. */
177
+ subtasks: { key: string; label: string; done: boolean }[];
168
178
  }
169
179
 
170
180
  // A due date N days from today — keeps the seeded urgency states (overdue / soon /
@@ -203,13 +213,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
203
213
 
204
214
  // The in-flight demo record's existing checklist (a NEW record gets []).
205
215
  const TASK_SEEDS: StageTask[] = [
206
- { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
207
- { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
208
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2) },
209
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1) },
210
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6) },
211
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "" },
212
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14) },
216
+ { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), note: "", subtasks: [] },
217
+ { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", note: "", subtasks: [] },
218
+ // two seeded NOTES the "why it's stuck" a task carries and nothing else
219
+ // holds; every other row shows the add affordance instead
220
+ { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), note: "Their finance desk answers after 15:00 only.", subtasks: [] },
221
+ // the one task with SUBTASKS a booking is a sequence, and its steps carry
222
+ // nothing but a tick, so they stay child rows instead of four more tasks
223
+ // (the row opens with work left; press the chevron to close it)
224
+ { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), note: "", subtasks: [
225
+ { key: "s1", label: "Confirm the pickup window", done: true },
226
+ { key: "s2", label: "Send the packing list", done: false },
227
+ { key: "s3", label: "Get the booking reference", done: false },
228
+ { key: "s4", label: "Share the reference with the customer", done: false },
229
+ ] },
230
+ { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), note: "Waiting on the signed copy from the yard.", subtasks: [] },
231
+ { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", note: "", subtasks: [] },
232
+ { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), note: "", subtasks: [] },
213
233
  ];
214
234
 
215
235
  // The assignable roster — an app feeds `useMembers()` here.
@@ -1138,11 +1158,11 @@ export function TplRecord() {
1138
1158
  const addTask = () => {
1139
1159
  const label = newTask.trim();
1140
1160
  if (!label) return;
1141
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
1161
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1142
1162
  setNewTask("");
1143
1163
  };
1144
1164
  const addSuggested = (desk: Desk, label: string) =>
1145
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
1165
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1146
1166
  const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
1147
1167
  const moveTask = (tid: string, desk: Desk) =>
1148
1168
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
@@ -1153,6 +1173,15 @@ export function TplRecord() {
1153
1173
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
1154
1174
  const dueTask = (tid: string, due: string) =>
1155
1175
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
1176
+ // Saving an EMPTY note is the removal — one write path, no separate delete.
1177
+ const noteTask = (tid: string, note: string) =>
1178
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, note } : t)));
1179
+ // A child step patches by key — the kit owns the expander and the count, the
1180
+ // app owns nothing but the tick.
1181
+ const toggleSubtask = (tid: string, key: string, done: boolean) =>
1182
+ setTasks((prev) =>
1183
+ prev.map((t) => (t.id === tid ? { ...t, subtasks: t.subtasks.map((s) => (s.key === key ? { ...s, done } : s)) } : t)),
1184
+ );
1156
1185
  // The Task-list grammar: a clearable Group-by + filter chips derive the
1157
1186
  // sections; empty groups drop (except desks — the journey stays visible).
1158
1187
  const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
@@ -1866,6 +1895,23 @@ export function TplRecord() {
1866
1895
  <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1867
1896
  </View>
1868
1897
  }
1898
+ // the row's own NOTE — the muted second line under the
1899
+ // title (the "why it's stuck" a task carries); the kit
1900
+ // owns the whole affordance, so nothing here hand-rolls
1901
+ // a line + editor. Empty = the add button on the row.
1902
+ note={{ value: t.note, onSave: (v) => noteTask(t.id, v), addLabel: `Add note: ${t.label}` }}
1903
+ // the row's CHILD STEPS — one task made of several
1904
+ // ticks. The kit renders the chevron + remaining count
1905
+ // on the parent and the indented child rows when open;
1906
+ // an EMPTY list renders nothing, so every row can pass
1907
+ // the prop unconditionally. Expansion is left
1908
+ // UNCONTROLLED: a row with work left opens itself.
1909
+ subtasks={{
1910
+ items: t.subtasks.map((s) => ({ key: s.key, label: s.label, checked: s.done, onToggle: (on) => toggleSubtask(t.id, s.key, on) })),
1911
+ // the row name matters once the list is long — the
1912
+ // locale default ("Subtasks: 3 left") can't know it
1913
+ countLabel: (missing) => (missing > 0 ? `Subtasks · ${t.label}: ${missing} left` : `Subtasks · ${t.label}: all done`),
1914
+ }}
1869
1915
  menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1870
1916
  >
1871
1917
  <InlineTextInput
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "16.1.0",
3
+ "version": "16.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -314,4 +314,4 @@
314
314
  "unified": "^11.0.5",
315
315
  "vite": "^7.2.4"
316
316
  }
317
- }
317
+ }
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,
@@ -90,6 +90,34 @@ export function useInlineEdit<T>(opts: {
90
90
  return { editing, draft, setDraft, saving, error, begin, cancel, commit };
91
91
  }
92
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
+
93
121
  /** Which surface an inline editor lives on — the ONE axis that separates a FORM
94
122
  * field from a data-grid CELL:
95
123
  * - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
@@ -243,7 +271,6 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
243
271
 
244
272
  const viewRef = useRef<View>(null);
245
273
  const suppressedAt = useRef<number | null>(null);
246
- const wasEditing = useRef(editing);
247
274
 
248
275
  // Keyboard focus opens edit mode immediately; the input's own `autoFocus`
249
276
  // then moves focus into it. Pointer focus never lands here (mousedown records
@@ -255,32 +282,15 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
255
282
  onBegin();
256
283
  }, [disabled, onBegin]);
257
284
 
258
- // When edit mode closes while its input (or a ✓/✕ button) still holds focus —
259
- // Enter, Escape, the buttons the unmount drops focus to <body> and the next
260
- // Tab would restart from the top of the page. Return focus to the view button,
261
- // arming the suppression window so the programmatic focus doesn't re-open the
262
- // editor it just closed. A Tab-away blur-commit leaves focus on the next field
263
- // (not <body>), so this never steals focus back.
264
- //
265
- // Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
266
- // continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
267
- // space) also drops focus to <body>, but restoring it there both steals focus
268
- // from a mouse user AND — because a bare `.focus()` scrolls the target into
269
- // view — jumps the scroll position (often to the top of the page). A mouse
270
- // user has no "next Tab" to preserve, so skip the restore for pointer closes.
271
- useEffect(() => {
272
- if (
273
- wasEditing.current &&
274
- !editing &&
275
- typeof document !== "undefined" &&
276
- document.activeElement === document.body &&
277
- shouldRestoreFocusOnClose(getInteractionModality())
278
- ) {
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(() => {
279
290
  suppressedAt.current = Date.now();
280
291
  viewRef.current?.focus();
281
- }
282
- wasEditing.current = editing;
283
- }, [editing]);
292
+ }, []),
293
+ );
284
294
 
285
295
  if (!editing) {
286
296
  return (
package/src/locale.tsx CHANGED
@@ -69,6 +69,18 @@ export interface LoticsLocale {
69
69
  trendFooter: { up: string; down: string };
70
70
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
71
71
  suggestionChip: { add: (label: string) => string; dismiss: string };
72
+ /** `ChecklistRow`'s per-row NOTE: the add affordance's name/tooltip, the note
73
+ * line's press name (it WRAPS the note text so the line is announced with
74
+ * it), and the editor's placeholder — plus the SUBTASK expander's name,
75
+ * given the count of unchecked children. The expander's open/closed state
76
+ * rides `aria-expanded`, so `subtasks` must NOT name it: one stable name,
77
+ * or every toggle re-announces the control. */
78
+ checklist: {
79
+ addNote: string;
80
+ editNote: (note: string) => string;
81
+ notePlaceholder: string;
82
+ subtasks: (missing: number) => string;
83
+ };
72
84
  /** `Confidence`: the full level phrase ("High confidence" …). */
73
85
  confidence: ConfidenceLabels;
74
86
  /** `Finding`: the severity badge word ("Critical" …). */
@@ -162,7 +174,7 @@ export const en: LoticsLocale = {
162
174
  descending: ", descending",
163
175
  },
164
176
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
165
- datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
177
+ datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
166
178
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
167
179
  filterChip: { clear: "Clear" },
168
180
  floatingActionBar: { clear: "Clear" },
@@ -176,6 +188,12 @@ export const en: LoticsLocale = {
176
188
  chip: { remove: "Remove" },
177
189
  trendFooter: { up: "Up", down: "Down" },
178
190
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
191
+ checklist: {
192
+ addNote: "Add note",
193
+ editNote: (note) => `Edit note: ${note}`,
194
+ notePlaceholder: "Add a note…",
195
+ subtasks: (missing) => (missing > 0 ? `Subtasks: ${missing} left` : "Subtasks: all done"),
196
+ },
179
197
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
180
198
  remainderMeter: {
181
199
  applied: (allocated, total) => `${allocated} of ${total} applied`,
@@ -260,7 +278,7 @@ export const vi: LoticsLocale = {
260
278
  descending: " (giảm dần)",
261
279
  },
262
280
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
263
- datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
281
+ datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
264
282
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
265
283
  filterChip: { clear: "Xóa" },
266
284
  floatingActionBar: { clear: "Bỏ chọn" },
@@ -274,6 +292,12 @@ export const vi: LoticsLocale = {
274
292
  chip: { remove: "Xóa" },
275
293
  trendFooter: { up: "Tăng", down: "Giảm" },
276
294
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
295
+ checklist: {
296
+ addNote: "Thêm ghi chú",
297
+ editNote: (note) => `Sửa ghi chú: ${note}`,
298
+ notePlaceholder: "Thêm ghi chú…",
299
+ subtasks: (missing) => (missing > 0 ? `Việc con: còn ${missing}` : "Việc con: đã xong"),
300
+ },
277
301
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
278
302
  remainderMeter: {
279
303
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,