@lotics/ui 16.2.0 → 17.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/task.tsx ADDED
@@ -0,0 +1,287 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+
5
+ /**
6
+ * The TASK compound — a list of things to be done, at any depth and any layout.
7
+ *
8
+ * It replaces `Checklist`/`ChecklistRow`, which was a configuration object: eight slot props,
9
+ * a `trailing`/`meta` pair that was mutually exclusive BY TYPE so an author had to predict
10
+ * "wide surface" or "narrow" when writing the JSX, and a `subtasks` array of
11
+ * `{label, checked}` a child could never outgrow. Every new task layout arrived as another
12
+ * prop. Composition takes those decisions back:
13
+ *
14
+ * <TaskList fieldsWidth={168}>
15
+ * <TaskItem>
16
+ * <TaskStatus><CheckCircle …/></TaskStatus>
17
+ * <TaskTitle><InlineTextInput variant="cell" struck={done} …/></TaskTitle>
18
+ * <TaskFields><InlineDatePicker …/><InlineMemberSelect …/></TaskFields>
19
+ * <TaskActions><ActionMenu …/></TaskActions>
20
+ * <TaskCaption>Waiting on the signed copy</TaskCaption>
21
+ * <TaskDetail>…</TaskDetail>
22
+ * <TaskList>…</TaskList> ← subtasks ARE tasks
23
+ * </TaskItem>
24
+ * </TaskList>
25
+ *
26
+ * Two properties follow from the shape rather than from props:
27
+ *
28
+ * **A subtask is a task.** Nesting a `TaskList` inside a `TaskItem` makes the child recursive
29
+ * by construction, so it carries a due date, a menu, its own children — everything the parent
30
+ * has. The old array shape made "subtasks need X" a permanent ticket queue.
31
+ *
32
+ * **The row reflows on its own.** `TaskTitle` claims a readable minimum width; when the row
33
+ * cannot seat the title and the fields together, flex-wrap drops `TaskFields` onto its own
34
+ * line. That is intrinsic — no viewport query, no measurement — so it behaves the same in a
35
+ * drawer, a split pane and a phone, which a breakpoint cannot.
36
+ *
37
+ * **A value and a sentence are different things.** `TaskFields` holds the cells the reader
38
+ * edits and scans DOWN a column (due, assignee), sized once by the list so they stay a column;
39
+ * `TaskCaption` holds a sentence ABOUT the row ("Needs: Carrier, Vehicle plate") on its own
40
+ * line under the title. The first cut of this family merged them, which pushed prose into the
41
+ * value column where it aligned with nothing.
42
+ *
43
+ * There is deliberately no `note` slot. A task's free text is its title, a caption, or detail;
44
+ * a fourth place to write invited writing it twice.
45
+ */
46
+
47
+ interface TaskListContextValue {
48
+ /** Width of the leading control column — nested lists and detail blocks indent by it. */
49
+ controlWidth: number;
50
+ /** Shared width of every row's field cluster, so the cells form a COLUMN. */
51
+ fieldsWidth?: number;
52
+ /** 0 at the root. Any value above 0 means "I am rendered inside a task". */
53
+ depth: number;
54
+ density: TaskDensity;
55
+ }
56
+
57
+ export type TaskDensity = "comfortable" | "dense";
58
+
59
+ const TaskListContext = createContext<TaskListContextValue>({
60
+ controlWidth: 20,
61
+ depth: 0,
62
+ density: "comfortable",
63
+ });
64
+
65
+ /** Below this a title is unreadable, so the fields take their own line instead. */
66
+ const TITLE_MIN_WIDTH = 180;
67
+ /** The minimum comfortable tap target — a finger, not a pointer. */
68
+ const TOUCH_TARGET = 44;
69
+ /** A pointer-driven register trades the target for rows on screen. */
70
+ const DENSE_ROW = 32;
71
+ /** Horizontal rhythm between the row's slots. */
72
+ const ROW_GAP = 12;
73
+ /**
74
+ * How far a `variant="cell"` control insets its own text: 1px of transparent border plus 8px
75
+ * of padding. Everything that hangs beneath a row — the caption, the detail, a nested list —
76
+ * adds the same, so it lines up with the WORDS rather than with the editor's invisible box. A
77
+ * plain-`Text` title carries it too, or it sits out of line with the editable titles around it.
78
+ *
79
+ * It was 8 — the padding alone, forgetting the border — which put every caption, detail block
80
+ * and nested list 1px left of the title it belonged to.
81
+ */
82
+ export const TASK_TEXT_INSET = 9;
83
+
84
+ export interface TaskListProps {
85
+ children: ReactNode;
86
+ /**
87
+ * Width of every row's leading control — `CheckCircle` 20 (the default), `CheckboxInput`
88
+ * 24. Detail blocks and nested lists indent by it so they sit on the title's text edge.
89
+ * Set on the ROOT list; a nested list inherits it.
90
+ */
91
+ controlWidth?: number;
92
+ /**
93
+ * `comfortable` (the default) gives every row a 44px minimum so a finger can hit it;
94
+ * `dense` drops to 32px for a pointer-driven register. Density belongs to the SURFACE, so
95
+ * it is set once here and inherited by nested lists.
96
+ */
97
+ density?: TaskDensity;
98
+ /**
99
+ * Fixed width for every row's `TaskFields`, so the due/assignee cells line up as a COLUMN
100
+ * down the list instead of drifting with each title's length. Set it once here — a row
101
+ * with fewer cells still reserves the width, which is what keeps the column straight.
102
+ * Omit it and each cluster hugs its own content (fine for a single row, jittery in a list).
103
+ */
104
+ fieldsWidth?: number;
105
+ accessibilityLabel?: string;
106
+ }
107
+
108
+ /**
109
+ * The list — owns geometry (control column, indent, density) for every descendant. Nest one
110
+ * inside a `TaskItem` and its rows become that task's subtasks, indented one column.
111
+ */
112
+ export function TaskList(props: TaskListProps) {
113
+ const parent = useContext(TaskListContext);
114
+ const isNested = parent.depth > 0;
115
+ const controlWidth = props.controlWidth ?? parent.controlWidth;
116
+ const density = props.density ?? parent.density;
117
+ const fieldsWidth = props.fieldsWidth ?? parent.fieldsWidth;
118
+ return (
119
+ <TaskListContext.Provider value={{ controlWidth, depth: parent.depth + 1, density, fieldsWidth }}>
120
+ <View
121
+ style={[
122
+ styles.list,
123
+ // A nested list is a child-step list: indent it to the parent's title text edge.
124
+ isNested && { flexBasis: "100%", marginTop: 2, marginBottom: 2 },
125
+ ]}
126
+ role="list"
127
+ accessibilityLabel={props.accessibilityLabel}
128
+ >
129
+ {props.children}
130
+ </View>
131
+ </TaskListContext.Provider>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * One task. Its children lay out as a WRAPPING row — `TaskStatus`, `TaskTitle`, `TaskFields`,
137
+ * `TaskActions` — while `TaskDetail` and a nested `TaskList` are full-width and fall beneath
138
+ * it. Order in JSX is order on screen; nothing inspects child types.
139
+ */
140
+ export function TaskItem(props: { children: ReactNode }) {
141
+ const { controlWidth } = useContext(TaskListContext);
142
+ // The control is taken OUT of the wrapping flow and pinned to the left gutter, so the row's
143
+ // content — title, fields, caption, detail, a nested list — all share ONE column. Anything
144
+ // that wraps therefore lands under the title instead of under the checkbox, which is what
145
+ // made a wrapped field cluster read as a row of its own, belonging to nothing.
146
+ //
147
+ // The minimum tap target is the ROW's, not each slot's: boxing every slot at 44px centred
148
+ // the title in it and pushed the caption ~13px below the words it describes.
149
+ return (
150
+ <View
151
+ style={[styles.item, { minHeight: useRowMinHeight(), paddingLeft: controlWidth + ROW_GAP }]}
152
+ role="listitem"
153
+ >
154
+ {props.children}
155
+ </View>
156
+ );
157
+ }
158
+
159
+ function useRowMinHeight(): number {
160
+ const { density } = useContext(TaskListContext);
161
+ return density === "dense" ? DENSE_ROW : TOUCH_TARGET;
162
+ }
163
+
164
+ /** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned to the
165
+ * list's control column so every title starts on the same edge. */
166
+ export function TaskStatus(props: { children: ReactNode }) {
167
+ const { controlWidth } = useContext(TaskListContext);
168
+ // Pinned to the gutter and sized to the FIRST line, so it stays beside the title however
169
+ // tall the row grows underneath it.
170
+ return (
171
+ <View style={[styles.status, { width: controlWidth, height: useRowMinHeight() }]}>
172
+ {props.children}
173
+ </View>
174
+ );
175
+ }
176
+
177
+ /** The task's identity — plain `Text` or an inline editor. Takes the row's spare width and
178
+ * claims a readable minimum, which is what makes `TaskFields` wrap rather than crush it. */
179
+ export function TaskTitle(props: { children: ReactNode }) {
180
+ return (
181
+ <View style={styles.title}>{props.children}</View>
182
+ );
183
+ }
184
+
185
+ /**
186
+ * The descriptor cluster — due date, assignee, chips, counts. Sits inline after the title
187
+ * while the row has room and takes its own line when it does not.
188
+ *
189
+ * Replaces the old `trailing` (wide) / `meta` (narrow) pair, which made the author pick one
190
+ * at authoring time and be wrong on the other surface.
191
+ */
192
+ export function TaskFields(props: { children: ReactNode }) {
193
+ const { fieldsWidth } = useContext(TaskListContext);
194
+ return (
195
+ <View
196
+ style={[
197
+ styles.fields,
198
+ // A reserved width is what makes a COLUMN: every row gives its cells the same box,
199
+ // so a row missing a due date does not pull its assignee leftward out of line.
200
+ fieldsWidth !== undefined && { width: fieldsWidth },
201
+ ]}
202
+ >
203
+ {props.children}
204
+ </View>
205
+ );
206
+ }
207
+
208
+ /**
209
+ * A second line under the title — what this task's STATE is, in words: "Needs: Carrier,
210
+ * Vehicle plate", "Waiting on the yard", "3 of 5 papers received".
211
+ *
212
+ * Deliberately NOT part of `TaskFields`. A field is a VALUE the reader edits and scans down a
213
+ * column; a caption is a SENTENCE about this row. Merging them (as the first cut of this
214
+ * family did) pushes prose into the value column, where it aligns with nothing and squeezes
215
+ * the cells. It always takes its own line, on the title's text edge — never inline, never
216
+ * behind a disclosure, because a state the reader must open to see is a state they will miss.
217
+ */
218
+ export function TaskCaption(props: { children: ReactNode }) {
219
+ return (
220
+ <View style={[styles.caption, { paddingLeft: TASK_TEXT_INSET }]}>{props.children}</View>
221
+ );
222
+ }
223
+
224
+ /** The row's actions — an `ActionMenu`, an `IconButton`. A destructive item belongs behind
225
+ * the menu, never as a bare ✕ on the row, so a stray tap cannot destroy a task. */
226
+ export function TaskActions(props: { children: ReactNode }) {
227
+ return (
228
+ <View style={styles.actions}>{props.children}</View>
229
+ );
230
+ }
231
+
232
+ /** A block beneath the row, indented to the title's text edge — an inline editor, a
233
+ * drill-down, the task's own fields. Render it only while open. */
234
+ export function TaskDetail(props: { children: ReactNode }) {
235
+ return (
236
+ <View style={[styles.detail, { marginLeft: TASK_TEXT_INSET }]}>{props.children}</View>
237
+ );
238
+ }
239
+
240
+ const styles = StyleSheet.create({
241
+ // No gap: every row already carries the density's min-height, so the rhythm is the ROW,
242
+ // not the space between rows. A gap on top of it made the list read as loose pairs.
243
+ list: { gap: 0 },
244
+ item: {
245
+ position: "relative",
246
+ flexDirection: "row",
247
+ flexWrap: "wrap",
248
+ alignItems: "center",
249
+ // `align-items` centres within a LINE; with `flex-wrap: wrap` it is `align-content` that
250
+ // places the lines in the box. Without it a short row (a plain-text subtask, 20px) sat at
251
+ // the top of its 44px target while the gutter control centred — the control read as
252
+ // belonging to the row below.
253
+ alignContent: "center",
254
+ columnGap: ROW_GAP,
255
+ // Lines inside ONE task are the same thought (title → caption → detail), so they sit
256
+ // tight; `gap` would have applied the 12px column rhythm vertically too.
257
+ rowGap: 2,
258
+ },
259
+ status: { position: "absolute", left: 0, top: 0, justifyContent: "center", alignItems: "flex-start" },
260
+ // flexBasis at the minimum (not 0) is what drives the wrap: once title + fields cannot
261
+ // both fit, the fields — which never shrink — are pushed to the next line.
262
+ title: {
263
+ flexGrow: 1,
264
+ flexShrink: 1,
265
+ flexBasis: TITLE_MIN_WIDTH,
266
+ minWidth: TITLE_MIN_WIDTH,
267
+ justifyContent: "center",
268
+ },
269
+ fields: {
270
+ flexDirection: "row",
271
+ alignItems: "center",
272
+ justifyContent: "flex-end",
273
+ // NOWRAP: the cluster is a ROW of cells, and it wraps as a UNIT under the title. Letting
274
+ // it wrap internally split the cells across two lines with the ⋯ stranded between them.
275
+ // Too little room means the cells compress, never that they stack.
276
+ flexWrap: "nowrap",
277
+ gap: 8,
278
+ // Shrinkable, so a narrow container compresses the cells instead of shoving the ⋯ onto a
279
+ // line of its own. `fieldsWidth` still pins the column when a list wants one.
280
+ flexShrink: 1,
281
+ minWidth: 0,
282
+ },
283
+ // `flexBasis: "100%"` is the whole trick: the caption always breaks to its own line.
284
+ caption: { flexBasis: "100%" },
285
+ actions: { flexGrow: 0, flexShrink: 0, justifyContent: "center" },
286
+ detail: { flexBasis: "100%", borderLeftWidth: 1, borderLeftColor: colors.border, paddingLeft: ROW_GAP },
287
+ });
package/src/checklist.tsx DELETED
@@ -1,377 +0,0 @@
1
- import { createContext, useCallback, useContext, useId, useRef, useState, type ReactNode } from "react";
2
- import { StyleSheet, View, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
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";
15
-
16
- // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
17
- // (row height, control/title alignment, the note/meta/subtask/expansion
18
- // indent, one trailing column width) while the CONTENT stays composed: tasks (a
19
- // `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
20
- // rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
21
- // readiness `meta` line and a fill editor in `expansion`) are the same
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
26
- // accidental destructive taps). Suggested tasks are NOT rows: offer the record
27
- // type's commons as `SuggestionChip`s under the list (tap = materialize,
28
- // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
29
- // list as its add affordance. There is deliberately NO monolithic Task
30
- // component — richer task-management rows (expand affordances, tag/clip meta,
31
- // board cards) compose their own anatomy directly.
32
- //
33
- // <Checklist trailingWidth={140}>
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) })) }}
38
- // menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
39
- // accessibilityLabel: "Task options: …" }}>
40
- // <InlineTextInput variant="cell" struck={done} … />
41
- // </ChecklistRow>
42
- // <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
43
- // <CaptureRow … />
44
- // </Checklist>
45
-
46
- interface ChecklistContextValue {
47
- trailingWidth?: number;
48
- controlWidth: number;
49
- }
50
-
51
- const ChecklistContext = createContext<ChecklistContextValue>({ controlWidth: 20 });
52
-
53
- export interface ChecklistProps {
54
- /** Fixed width of every row's trailing cell (an assignee select) — set it
55
- * once so the column lines up; omit when rows carry no trailing. */
56
- trailingWidth?: number;
57
- /** Width of every row's leading control — `CheckCircle` 20 (the default),
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. */
61
- controlWidth?: number;
62
- children: ReactNode;
63
- }
64
-
65
- export function Checklist(props: ChecklistProps) {
66
- const { trailingWidth, controlWidth = 20, children } = props;
67
- return (
68
- <ChecklistContext.Provider value={{ trailingWidth, controlWidth }}>
69
- <View style={styles.list}>{children}</View>
70
- </ChecklistContext.Provider>
71
- );
72
- }
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
-
141
- interface ChecklistRowBaseProps {
142
- /** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
143
- control: ReactNode;
144
- /** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
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;
152
- /** The row's ⋯ options menu (destructive items last, `danger: true`) —
153
- * Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
154
- * destroy a task. Name the task in the label ("Task options: Book the
155
- * carrier"). The ⋯ column aligns only when every row in the list carries
156
- * a menu — keep its presence uniform per list. */
157
- menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
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. */
168
- expansion?: ReactNode;
169
- }
170
-
171
- /** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
172
- export type ChecklistRowProps = ChecklistRowBaseProps &
173
- (
174
- | {
175
- /** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
176
- trailing?: ReactNode;
177
- meta?: never;
178
- }
179
- | {
180
- /** SECOND line under the title (indented past the ring) for a NARROW
181
- * surface — a drawer or popover checklist: put the assignee/due
182
- * editors here so the title keeps the full width and stays readable. */
183
- meta?: ReactNode;
184
- trailing?: never;
185
- }
186
- );
187
-
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). */
193
- export function ChecklistRow(props: ChecklistRowProps) {
194
- const { control, children, trailing, menu, meta, note, subtasks, expansion } = props;
195
- const { trailingWidth, controlWidth } = useContext(ChecklistContext);
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.
237
- const indent = controlWidth + 12;
238
- return (
239
- <View>
240
- <View style={styles.row}>
241
- {control}
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}
280
- {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
281
- {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
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}
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}
348
- {expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
349
- </View>
350
- );
351
- }
352
-
353
- const styles = StyleSheet.create({
354
- list: { gap: 4 },
355
- row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
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] },
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 },
376
- expansion: { paddingTop: 2, paddingBottom: 6 },
377
- });