@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 +43 -7
- package/docs/composition.md +3 -1
- package/docs/data_entry.md +16 -2
- package/docs/templates.md +6 -1
- package/examples/tpl_record.tsx +55 -9
- package/package.json +2 -2
- package/src/checklist.tsx +275 -17
- package/src/date_picker.tsx +21 -0
- package/src/detail_row.tsx +14 -4
- package/src/form_date_picker.tsx +3 -1
- package/src/form_field.tsx +4 -1
- package/src/icon.tsx +2 -0
- package/src/inline_edit.tsx +35 -25
- package/src/locale.tsx +26 -2
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
|
-
`
|
|
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
|
|
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. `
|
|
755
|
-
|
|
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
|
package/docs/composition.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/data_entry.md
CHANGED
|
@@ -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
|
|
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).
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
|
|
209
|
-
|
|
210
|
-
{ id: "
|
|
211
|
-
|
|
212
|
-
|
|
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
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
|
|
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. `
|
|
12
|
-
//
|
|
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
|
|
43
|
-
*
|
|
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
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
92
|
-
*
|
|
93
|
-
* optional `
|
|
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
|
-
|
|
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}
|
|
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
|
});
|
package/src/date_picker.tsx
CHANGED
|
@@ -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}
|
package/src/detail_row.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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.
|
package/src/form_date_picker.tsx
CHANGED
|
@@ -19,7 +19,9 @@ export function FormDatePicker(props: FormDatePickerProps) {
|
|
|
19
19
|
optional={optional}
|
|
20
20
|
optionalLabel={optionalLabel}
|
|
21
21
|
>
|
|
22
|
-
|
|
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
|
}
|
package/src/form_field.tsx
CHANGED
|
@@ -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}
|
|
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,
|
package/src/inline_edit.tsx
CHANGED
|
@@ -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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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}`,
|