@lotics/ui 16.1.0 → 17.0.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.
@@ -90,6 +90,34 @@ export function useInlineEdit<T>(opts: {
90
90
  return { editing, draft, setDraft, saving, error, begin, cancel, commit };
91
91
  }
92
92
 
93
+ /**
94
+ * Keyboard TAB-ORDER continuity for any inline editor: when edit mode closes
95
+ * while its input still holds focus (Enter, Escape, a ✓/✕), the unmount drops
96
+ * focus to `<body>` and the next Tab would restart from the top of the page —
97
+ * so `restore` is called to put focus back on the RESTING control.
98
+ *
99
+ * Gated on KEYBOARD modality: a POINTER close also drops focus to `<body>`, but
100
+ * restoring there both steals focus from a mouse user AND — because a bare
101
+ * `.focus()` scrolls the target into view — jumps the scroll position. A mouse
102
+ * user has no "next Tab" to preserve. A Tab-away blur-commit leaves focus on the
103
+ * next field (not `<body>`), so this never steals focus back either.
104
+ */
105
+ export function useInlineEditFocusRestore(editing: boolean, restore: () => void) {
106
+ const wasEditing = useRef(editing);
107
+ useEffect(() => {
108
+ if (
109
+ wasEditing.current &&
110
+ !editing &&
111
+ typeof document !== "undefined" &&
112
+ document.activeElement === document.body &&
113
+ shouldRestoreFocusOnClose(getInteractionModality())
114
+ ) {
115
+ restore();
116
+ }
117
+ wasEditing.current = editing;
118
+ }, [editing, restore]);
119
+ }
120
+
93
121
  /** Which surface an inline editor lives on — the ONE axis that separates a FORM
94
122
  * field from a data-grid CELL:
95
123
  * - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
@@ -243,7 +271,6 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
243
271
 
244
272
  const viewRef = useRef<View>(null);
245
273
  const suppressedAt = useRef<number | null>(null);
246
- const wasEditing = useRef(editing);
247
274
 
248
275
  // Keyboard focus opens edit mode immediately; the input's own `autoFocus`
249
276
  // then moves focus into it. Pointer focus never lands here (mousedown records
@@ -255,32 +282,15 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
255
282
  onBegin();
256
283
  }, [disabled, onBegin]);
257
284
 
258
- // When edit mode closes while its input (or a ✓/✕ button) still holds focus —
259
- // Enter, Escape, the buttons the unmount drops focus to <body> and the next
260
- // Tab would restart from the top of the page. Return focus to the view button,
261
- // arming the suppression window so the programmatic focus doesn't re-open the
262
- // editor it just closed. A Tab-away blur-commit leaves focus on the next field
263
- // (not <body>), so this never steals focus back.
264
- //
265
- // Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
266
- // continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
267
- // space) also drops focus to <body>, but restoring it there both steals focus
268
- // from a mouse user AND — because a bare `.focus()` scrolls the target into
269
- // view — jumps the scroll position (often to the top of the page). A mouse
270
- // user has no "next Tab" to preserve, so skip the restore for pointer closes.
271
- useEffect(() => {
272
- if (
273
- wasEditing.current &&
274
- !editing &&
275
- typeof document !== "undefined" &&
276
- document.activeElement === document.body &&
277
- shouldRestoreFocusOnClose(getInteractionModality())
278
- ) {
285
+ // Focus returns to the view button on a keyboard close, arming the suppression
286
+ // window so the programmatic focus doesn't re-open the editor it just closed.
287
+ useInlineEditFocusRestore(
288
+ editing,
289
+ useCallback(() => {
279
290
  suppressedAt.current = Date.now();
280
291
  viewRef.current?.focus();
281
- }
282
- wasEditing.current = editing;
283
- }, [editing]);
292
+ }, []),
293
+ );
284
294
 
285
295
  if (!editing) {
286
296
  return (
package/src/locale.tsx CHANGED
@@ -162,7 +162,7 @@ export const en: LoticsLocale = {
162
162
  descending: ", descending",
163
163
  },
164
164
  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" },
165
+ 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
166
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
167
167
  filterChip: { clear: "Clear" },
168
168
  floatingActionBar: { clear: "Clear" },
@@ -260,7 +260,7 @@ export const vi: LoticsLocale = {
260
260
  descending: " (giảm dần)",
261
261
  },
262
262
  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 đủ" },
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", 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
264
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
265
265
  filterChip: { clear: "Xóa" },
266
266
  floatingActionBar: { clear: "Bỏ chọn" },
package/src/task.tsx ADDED
@@ -0,0 +1,285 @@
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
+ * The inline controls rest inside 8px of horizontal padding, so a `variant="cell"` title's
75
+ * TEXT starts 8px inside its box. Everything that hangs beneath a row — the caption, the
76
+ * detail, a nested list — adds the same inset, so it lines up with the words rather than with
77
+ * the editor's invisible box. A plain-`Text` title should carry it too (`TASK_TEXT_INSET`),
78
+ * or its caption will sit 8px to its right.
79
+ */
80
+ export const TASK_TEXT_INSET = 8;
81
+
82
+ export interface TaskListProps {
83
+ children: ReactNode;
84
+ /**
85
+ * Width of every row's leading control — `CheckCircle` 20 (the default), `CheckboxInput`
86
+ * 24. Detail blocks and nested lists indent by it so they sit on the title's text edge.
87
+ * Set on the ROOT list; a nested list inherits it.
88
+ */
89
+ controlWidth?: number;
90
+ /**
91
+ * `comfortable` (the default) gives every row a 44px minimum so a finger can hit it;
92
+ * `dense` drops to 32px for a pointer-driven register. Density belongs to the SURFACE, so
93
+ * it is set once here and inherited by nested lists.
94
+ */
95
+ density?: TaskDensity;
96
+ /**
97
+ * Fixed width for every row's `TaskFields`, so the due/assignee cells line up as a COLUMN
98
+ * down the list instead of drifting with each title's length. Set it once here — a row
99
+ * with fewer cells still reserves the width, which is what keeps the column straight.
100
+ * Omit it and each cluster hugs its own content (fine for a single row, jittery in a list).
101
+ */
102
+ fieldsWidth?: number;
103
+ accessibilityLabel?: string;
104
+ }
105
+
106
+ /**
107
+ * The list — owns geometry (control column, indent, density) for every descendant. Nest one
108
+ * inside a `TaskItem` and its rows become that task's subtasks, indented one column.
109
+ */
110
+ export function TaskList(props: TaskListProps) {
111
+ const parent = useContext(TaskListContext);
112
+ const isNested = parent.depth > 0;
113
+ const controlWidth = props.controlWidth ?? parent.controlWidth;
114
+ const density = props.density ?? parent.density;
115
+ const fieldsWidth = props.fieldsWidth ?? parent.fieldsWidth;
116
+ return (
117
+ <TaskListContext.Provider value={{ controlWidth, depth: parent.depth + 1, density, fieldsWidth }}>
118
+ <View
119
+ style={[
120
+ styles.list,
121
+ // A nested list is a child-step list: indent it to the parent's title text edge.
122
+ isNested && { flexBasis: "100%", marginTop: 2, marginBottom: 2 },
123
+ ]}
124
+ role="list"
125
+ accessibilityLabel={props.accessibilityLabel}
126
+ >
127
+ {props.children}
128
+ </View>
129
+ </TaskListContext.Provider>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * One task. Its children lay out as a WRAPPING row — `TaskStatus`, `TaskTitle`, `TaskFields`,
135
+ * `TaskActions` — while `TaskDetail` and a nested `TaskList` are full-width and fall beneath
136
+ * it. Order in JSX is order on screen; nothing inspects child types.
137
+ */
138
+ export function TaskItem(props: { children: ReactNode }) {
139
+ const { controlWidth } = useContext(TaskListContext);
140
+ // The control is taken OUT of the wrapping flow and pinned to the left gutter, so the row's
141
+ // content — title, fields, caption, detail, a nested list — all share ONE column. Anything
142
+ // that wraps therefore lands under the title instead of under the checkbox, which is what
143
+ // made a wrapped field cluster read as a row of its own, belonging to nothing.
144
+ //
145
+ // The minimum tap target is the ROW's, not each slot's: boxing every slot at 44px centred
146
+ // the title in it and pushed the caption ~13px below the words it describes.
147
+ return (
148
+ <View
149
+ style={[styles.item, { minHeight: useRowMinHeight(), paddingLeft: controlWidth + ROW_GAP }]}
150
+ role="listitem"
151
+ >
152
+ {props.children}
153
+ </View>
154
+ );
155
+ }
156
+
157
+ function useRowMinHeight(): number {
158
+ const { density } = useContext(TaskListContext);
159
+ return density === "dense" ? DENSE_ROW : TOUCH_TARGET;
160
+ }
161
+
162
+ /** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned to the
163
+ * list's control column so every title starts on the same edge. */
164
+ export function TaskStatus(props: { children: ReactNode }) {
165
+ const { controlWidth } = useContext(TaskListContext);
166
+ // Pinned to the gutter and sized to the FIRST line, so it stays beside the title however
167
+ // tall the row grows underneath it.
168
+ return (
169
+ <View style={[styles.status, { width: controlWidth, height: useRowMinHeight() }]}>
170
+ {props.children}
171
+ </View>
172
+ );
173
+ }
174
+
175
+ /** The task's identity — plain `Text` or an inline editor. Takes the row's spare width and
176
+ * claims a readable minimum, which is what makes `TaskFields` wrap rather than crush it. */
177
+ export function TaskTitle(props: { children: ReactNode }) {
178
+ return (
179
+ <View style={styles.title}>{props.children}</View>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * The descriptor cluster — due date, assignee, chips, counts. Sits inline after the title
185
+ * while the row has room and takes its own line when it does not.
186
+ *
187
+ * Replaces the old `trailing` (wide) / `meta` (narrow) pair, which made the author pick one
188
+ * at authoring time and be wrong on the other surface.
189
+ */
190
+ export function TaskFields(props: { children: ReactNode }) {
191
+ const { fieldsWidth } = useContext(TaskListContext);
192
+ return (
193
+ <View
194
+ style={[
195
+ styles.fields,
196
+ // A reserved width is what makes a COLUMN: every row gives its cells the same box,
197
+ // so a row missing a due date does not pull its assignee leftward out of line.
198
+ fieldsWidth !== undefined && { width: fieldsWidth },
199
+ ]}
200
+ >
201
+ {props.children}
202
+ </View>
203
+ );
204
+ }
205
+
206
+ /**
207
+ * A second line under the title — what this task's STATE is, in words: "Needs: Carrier,
208
+ * Vehicle plate", "Waiting on the yard", "3 of 5 papers received".
209
+ *
210
+ * Deliberately NOT part of `TaskFields`. A field is a VALUE the reader edits and scans down a
211
+ * column; a caption is a SENTENCE about this row. Merging them (as the first cut of this
212
+ * family did) pushes prose into the value column, where it aligns with nothing and squeezes
213
+ * the cells. It always takes its own line, on the title's text edge — never inline, never
214
+ * behind a disclosure, because a state the reader must open to see is a state they will miss.
215
+ */
216
+ export function TaskCaption(props: { children: ReactNode }) {
217
+ return (
218
+ <View style={[styles.caption, { paddingLeft: TASK_TEXT_INSET }]}>{props.children}</View>
219
+ );
220
+ }
221
+
222
+ /** The row's actions — an `ActionMenu`, an `IconButton`. A destructive item belongs behind
223
+ * the menu, never as a bare ✕ on the row, so a stray tap cannot destroy a task. */
224
+ export function TaskActions(props: { children: ReactNode }) {
225
+ return (
226
+ <View style={styles.actions}>{props.children}</View>
227
+ );
228
+ }
229
+
230
+ /** A block beneath the row, indented to the title's text edge — an inline editor, a
231
+ * drill-down, the task's own fields. Render it only while open. */
232
+ export function TaskDetail(props: { children: ReactNode }) {
233
+ return (
234
+ <View style={[styles.detail, { marginLeft: TASK_TEXT_INSET }]}>{props.children}</View>
235
+ );
236
+ }
237
+
238
+ const styles = StyleSheet.create({
239
+ // No gap: every row already carries the density's min-height, so the rhythm is the ROW,
240
+ // not the space between rows. A gap on top of it made the list read as loose pairs.
241
+ list: { gap: 0 },
242
+ item: {
243
+ position: "relative",
244
+ flexDirection: "row",
245
+ flexWrap: "wrap",
246
+ alignItems: "center",
247
+ // `align-items` centres within a LINE; with `flex-wrap: wrap` it is `align-content` that
248
+ // places the lines in the box. Without it a short row (a plain-text subtask, 20px) sat at
249
+ // the top of its 44px target while the gutter control centred — the control read as
250
+ // belonging to the row below.
251
+ alignContent: "center",
252
+ columnGap: ROW_GAP,
253
+ // Lines inside ONE task are the same thought (title → caption → detail), so they sit
254
+ // tight; `gap` would have applied the 12px column rhythm vertically too.
255
+ rowGap: 2,
256
+ },
257
+ status: { position: "absolute", left: 0, top: 0, justifyContent: "center", alignItems: "flex-start" },
258
+ // flexBasis at the minimum (not 0) is what drives the wrap: once title + fields cannot
259
+ // both fit, the fields — which never shrink — are pushed to the next line.
260
+ title: {
261
+ flexGrow: 1,
262
+ flexShrink: 1,
263
+ flexBasis: TITLE_MIN_WIDTH,
264
+ minWidth: TITLE_MIN_WIDTH,
265
+ justifyContent: "center",
266
+ },
267
+ fields: {
268
+ flexDirection: "row",
269
+ alignItems: "center",
270
+ justifyContent: "flex-end",
271
+ // NOWRAP: the cluster is a ROW of cells, and it wraps as a UNIT under the title. Letting
272
+ // it wrap internally split the cells across two lines with the ⋯ stranded between them.
273
+ // Too little room means the cells compress, never that they stack.
274
+ flexWrap: "nowrap",
275
+ gap: 8,
276
+ // Shrinkable, so a narrow container compresses the cells instead of shoving the ⋯ onto a
277
+ // line of its own. `fieldsWidth` still pins the column when a list wants one.
278
+ flexShrink: 1,
279
+ minWidth: 0,
280
+ },
281
+ // `flexBasis: "100%"` is the whole trick: the caption always breaks to its own line.
282
+ caption: { flexBasis: "100%" },
283
+ actions: { flexGrow: 0, flexShrink: 0, justifyContent: "center" },
284
+ detail: { flexBasis: "100%", borderLeftWidth: 1, borderLeftColor: colors.border, paddingLeft: ROW_GAP },
285
+ });
package/src/checklist.tsx DELETED
@@ -1,119 +0,0 @@
1
- import { createContext, useContext, type ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
3
- import { ActionMenu, type ActionMenuItem } from "./action_menu";
4
-
5
- // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
6
- // (row height, control/title alignment, the meta/expansion indent, one
7
- // trailing column width) while the CONTENT stays composed: tasks (a
8
- // `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
9
- // rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
10
- // readiness `meta` line and a fill editor in `expansion`) are the same
11
- // anatomy. `menu` carries the row's ⋯ options (Delete lives BEHIND the menu
12
- // — indirection that prevents
13
- // accidental destructive taps). Suggested tasks are NOT rows: offer the record
14
- // type's commons as `SuggestionChip`s under the list (tap = materialize,
15
- // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
16
- // list as its add affordance. There is deliberately NO monolithic Task
17
- // component — richer task-management rows (expand affordances, tag/clip meta,
18
- // board cards) compose their own anatomy directly.
19
- //
20
- // <Checklist trailingWidth={140}>
21
- // <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
22
- // menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
23
- // accessibilityLabel: "Task options: …" }}>
24
- // <InlineTextInput variant="cell" struck={done} … />
25
- // </ChecklistRow>
26
- // <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
27
- // <CaptureRow … />
28
- // </Checklist>
29
-
30
- interface ChecklistContextValue {
31
- trailingWidth?: number;
32
- controlWidth: number;
33
- }
34
-
35
- const ChecklistContext = createContext<ChecklistContextValue>({ controlWidth: 20 });
36
-
37
- export interface ChecklistProps {
38
- /** Fixed width of every row's trailing cell (an assignee select) — set it
39
- * once so the column lines up; omit when rows carry no trailing. */
40
- trailingWidth?: number;
41
- /** Width of every row's leading control — `CheckCircle` 20 (the default),
42
- * `CheckboxInput` 24. The `meta`/`expansion` indent derives from it, so
43
- * the second line always sits on the title's text edge. */
44
- controlWidth?: number;
45
- children: ReactNode;
46
- }
47
-
48
- export function Checklist(props: ChecklistProps) {
49
- const { trailingWidth, controlWidth = 20, children } = props;
50
- return (
51
- <ChecklistContext.Provider value={{ trailingWidth, controlWidth }}>
52
- <View style={styles.list}>{children}</View>
53
- </ChecklistContext.Provider>
54
- );
55
- }
56
-
57
- interface ChecklistRowBaseProps {
58
- /** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
59
- control: ReactNode;
60
- /** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
61
- children: ReactNode;
62
- /** The row's ⋯ options menu (destructive items last, `danger: true`) —
63
- * Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
64
- * destroy a task. Name the task in the label ("Task options: Book the
65
- * carrier"). The ⋯ column aligns only when every row in the list carries
66
- * a menu — keep its presence uniform per list. */
67
- menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
68
- /** A BLOCK slot under the row (below `meta`), indented to the title's text
69
- * edge — a transient inline fill editor, a drill-down. Render it only
70
- * while open; the list's geometry (the indent) is owned here. */
71
- expansion?: ReactNode;
72
- }
73
-
74
- /** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
75
- export type ChecklistRowProps = ChecklistRowBaseProps &
76
- (
77
- | {
78
- /** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
79
- trailing?: ReactNode;
80
- meta?: never;
81
- }
82
- | {
83
- /** SECOND line under the title (indented past the ring) for a NARROW
84
- * surface — a drawer or popover checklist: put the assignee/due
85
- * editors here so the title keeps the full width and stays readable. */
86
- meta?: ReactNode;
87
- trailing?: never;
88
- }
89
- );
90
-
91
- /** One checklist line: control · title (flex) · the aligned trailing cell · ⋯,
92
- * with an optional indented `meta` line below for narrow surfaces and an
93
- * optional `expansion` block under that (both sit on the title's text edge). */
94
- export function ChecklistRow(props: ChecklistRowProps) {
95
- const { control, children, trailing, menu, meta, expansion } = props;
96
- const { trailingWidth, controlWidth } = useContext(ChecklistContext);
97
- // Indent past the control + the row gap so meta/expansion align with the title.
98
- const indent = controlWidth + 12;
99
- return (
100
- <View>
101
- <View style={styles.row}>
102
- {control}
103
- <View style={styles.title}>{children}</View>
104
- {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
105
- {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
106
- </View>
107
- {meta != null ? <View style={[styles.meta, { paddingLeft: indent }]}>{meta}</View> : null}
108
- {expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
109
- </View>
110
- );
111
- }
112
-
113
- const styles = StyleSheet.create({
114
- list: { gap: 4 },
115
- row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
116
- title: { flex: 1 },
117
- meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
118
- expansion: { paddingTop: 2, paddingBottom: 6 },
119
- });