@lotics/ui 18.1.0 → 19.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.
package/src/task.tsx CHANGED
@@ -1,80 +1,144 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
+ import { DETAIL_LABEL_WIDTH } from "./detail_row";
5
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
+ import { Text } from "./text";
7
+ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_metrics";
4
8
 
5
9
  /**
6
- * The TASK compound — a list of things to be done, at any depth and any layout.
10
+ * The TASK compound — a list of things to be done, at any depth.
7
11
  *
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}>
12
+ * <TaskList>
15
13
  * <TaskItem>
16
14
  * <TaskStatus><CheckCircle …/></TaskStatus>
17
15
  * <TaskTitle><InlineTextInput variant="cell" struck={done} …/></TaskTitle>
18
- * <TaskFields><InlineDatePicker …/><InlineMemberSelect …/></TaskFields>
19
16
  * <TaskActions><ActionMenu …/></TaskActions>
20
17
  * <TaskCaption>Waiting on the signed copy</TaskCaption>
18
+ * <TaskSubRow label="Portal account"><InlineTextInput …/></TaskSubRow>
19
+ * <TaskSubRow label="Completed on"><InlineDatePicker …/></TaskSubRow>
21
20
  * <TaskDetail>…</TaskDetail>
22
21
  * <TaskList>…</TaskList> ← subtasks ARE tasks
23
22
  * </TaskItem>
23
+ * <TaskItem>
24
+ * <TaskStatus><CheckCircle …/></TaskStatus>
25
+ * <TaskTitle struck={done}>Identity card</TaskTitle> ← a read-only title is its STRING
26
+ * </TaskItem>
24
27
  * </TaskList>
25
28
  *
26
- * Two properties follow from the shape rather than from props:
29
+ * **A row carries its TITLE. The fields the reader can SET hang under it as sub-rows.** A task
30
+ * row is one thing said in one line; a field is a NAME and a VALUE, and the two belong side by
31
+ * side so the eye pairs them without travelling. This family spent a version trying to be a
32
+ * table — a shared value column every row reserved, an action gutter to keep that column
33
+ * straight, packing arithmetic to divide it — and the shape it produced put a two-word label a
34
+ * quarter of the surface away from the input it named. Alignment was never the goal; legibility
35
+ * was.
27
36
  *
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.
37
+ * **Two GUTTERS, one content box.** Both of a row's pinned controls sit OUTSIDE the flow — the
38
+ * status in the LEFT gutter, the in the RIGHT and the item reserves each as padding: a gutter
39
+ * is its control's width plus the row gap (`taskGutter`), declared once on the list and inherited
40
+ * to every depth. What is left between them is ONE box, and every line the item renders spans
41
+ * exactly it: the title, a `TaskCaption`, a `TaskSubRow`, a `TaskDetail`, a nested `TaskList`.
42
+ * That is what holds the right edge straight on a list where only SOME rows carry a menu — the ⋯
43
+ * is a COLUMN the list reserves, not a width whichever row has one happens to stop before. A list
44
+ * whose rows carry no actions at all declines the column with `actionWidth={0}`.
31
45
  *
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.
46
+ * **Belonging is INDENTATION** the LEFT gutter, again. Everything a task owns, its fields and
47
+ * its subtasks, steps right by exactly one of them. That is the only device: no rules to draw, no
48
+ * width to declare, and it compounds correctly, so a sub-field of a subtask reads as one at a
49
+ * glance. The right gutter is the one that must NOT compound, so a nested list hands the parent's
50
+ * back before its own rows re-take it; otherwise the menu column walks inward a step per level.
51
+ * `TaskDetail` is the exception that proves the indent — a free-form block has no label to hang
52
+ * on it, so it keeps a rule instead.
36
53
  *
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.
54
+ * **A task list is not a table.** Anything the reader scans DOWN a column six rows compared on
55
+ * the same four values — is a `Table` or a `DataGrid`, which are built for it. A `TaskList` sized
56
+ * for that is a table with a worse header.
57
+ *
58
+ * **A subtask is a task.** Nesting a `TaskList` inside a `TaskItem` makes the child recursive by
59
+ * construction, so it carries its own fields, a menu, children — everything the parent has. An
60
+ * array of `{label, checked}` made "subtasks need X" a permanent ticket queue.
61
+ *
62
+ * **The control column is straight because the first line is a BAND.** The leading control is
63
+ * pinned to the gutter, so its centre is a CONSTANT — half the density's band. The row's first
64
+ * line therefore has to BE that band, which is why `TaskTitle` claims it (and, for a plain-text
65
+ * title, pads it symmetrically so a WRAPPED title keeps its first line in place). Without that,
66
+ * anything hanging below the title — a caption, a sub-row, a nested list — grew the row, the
67
+ * wrapping content packed to the top, and the control sagged into the gap under the title it
68
+ * belongs to. The same law pins the ⋯ on the right. The geometry is `task_metrics.ts`, where it
69
+ * can be tested.
70
+ *
71
+ * **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
72
+ * sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
73
+ * title. Prose given a label reads as a field nobody can edit.
42
74
  *
43
75
  * There is deliberately no `note` slot. A task's free text is its title, a caption, or detail;
44
76
  * a fourth place to write invited writing it twice.
45
77
  */
46
78
 
47
79
  interface TaskListContextValue {
48
- /** Width of the leading control column nested lists and detail blocks indent by it. */
80
+ /** The LEFT gutter's control — the leading ring or checkbox. One indent step is its gutter. */
49
81
  controlWidth: number;
50
- /** Shared width of every row's field cluster, so the cells form a COLUMN. */
51
- fieldsWidth?: number;
82
+ /** The RIGHT gutter's control — the row's ⋯. `0` on a list whose rows carry no actions. */
83
+ actionWidth: number;
52
84
  /** 0 at the root. Any value above 0 means "I am rendered inside a task". */
53
85
  depth: number;
54
86
  density: TaskDensity;
55
87
  }
56
88
 
57
- export type TaskDensity = "comfortable" | "dense";
89
+ export type TaskDensity = keyof typeof TASK_ROW_BAND;
90
+
91
+ /**
92
+ * The ⋯ gutter's default: an `ActionMenu` — and any `IconButton` at its default `md` — is 28
93
+ * across. A constant rather than a required prop because the slot's occupant is KNOWN: every task
94
+ * surface in the product reserves the same column without declaring anything, and a list that
95
+ * forgot to declare would otherwise be the misaligned one. The failure it cannot have is silent —
96
+ * a list that reserves a column it does not use is 28px narrower; a list that reserves nothing
97
+ * puts every hanging line under its own menu.
98
+ */
99
+ const TASK_ACTION_WIDTH = 28;
58
100
 
59
101
  const TaskListContext = createContext<TaskListContextValue>({
60
102
  controlWidth: 20,
103
+ actionWidth: TASK_ACTION_WIDTH,
61
104
  depth: 0,
62
105
  density: "comfortable",
63
106
  });
64
107
 
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;
108
+ /**
109
+ * A sub-row's label column. It is sized to its OWN content — no measurement, no width from the
110
+ * list between a floor and a ceiling, which is what makes the labels of ONE task line up with
111
+ * each other: a field name is two or three words, every one of them lands under the floor, so
112
+ * every value in that task starts on the same x. The floor IS the kit's label column
113
+ * (`DETAIL_LABEL_WIDTH`, `DetailTable`'s default) — ~18 characters, where real field names live
114
+ * ("Completed on", "Portal account", "Courier reference") — taken from that one constant rather
115
+ * than matched to it, so a task's fields and a record's cannot drift apart on a surface carrying
116
+ * both.
117
+ *
118
+ * The ceiling is the honest half of the deal. A label longer than the floor takes the width it
119
+ * needs and its value sits outboard of its siblings' — until the ceiling, where it wraps instead,
120
+ * so one verbose name cannot push the whole column off the surface. A TRUE shared column (every
121
+ * label at the widest sibling's width, whatever that is) cannot be had in flexbox without
122
+ * measuring text, and a measured column costs a layout pass, a re-render and a rule this file
123
+ * could not state — for a case that mostly does not arise.
124
+ */
125
+ const SUB_LABEL_MIN = DETAIL_LABEL_WIDTH;
126
+ const SUB_LABEL_MAX = 168;
127
+ /**
128
+ * The width a value claims before it would rather WRAP. It is set by what a real value needs —
129
+ * an email address, a member's name, a three-word status all run to about 185px — so below it,
130
+ * on a phone or in a narrow drawer, the value takes its OWN line under the label and gets the
131
+ * whole width instead of ellipsizing at seventeen characters beside it. A pair that fits only by
132
+ * hiding the value has not fitted.
133
+ */
134
+ const SUB_VALUE_MIN = 200;
73
135
  /**
74
136
  * 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.
137
+ * of padding. Everything that hangs beneath a row — the caption, a sub-row's label, the detail
138
+ * block — adds the same, so it lines up with the WORDS rather than with the editor's invisible
139
+ * box. A STRING title gets it from `TaskTitle`; only a custom title NODE that is not a
140
+ * `variant="cell"` control has to add it, or it sits out of line with the editable titles
141
+ * around it.
78
142
  *
79
143
  * It was 8 — the padding alone, forgetting the border — which put every caption, detail block
80
144
  * and nested list 1px left of the title it belonged to.
@@ -84,44 +148,52 @@ export const TASK_TEXT_INSET = 9;
84
148
  export interface TaskListProps {
85
149
  children: ReactNode;
86
150
  /**
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.
151
+ * Width of every row's leading control, which the LEFT gutter is reserved for — `CheckCircle`
152
+ * 20 (the default), `CheckboxInput` 24. It also sets the INDENT: everything hanging under a
153
+ * task its sub-rows, a nested list steps right by this plus the row gap. Set on the ROOT
154
+ * list; a nested list inherits it.
90
155
  */
91
156
  controlWidth?: number;
92
157
  /**
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.
158
+ * Width of every row's trailing action, which the RIGHT gutter is reserved for — an
159
+ * `ActionMenu` (or any default `IconButton`) 28 (the default), a `size="sm"` one 24. EVERY row
160
+ * reserves it, which is what keeps a list where only SOME rows carry a ⋯ straight: their
161
+ * content ends on the same x as the menu-less rows'. Pass `0` on a list whose rows carry no
162
+ * actions at all, so it pays nothing for a column it never uses. Set on the ROOT list; a
163
+ * nested list inherits it.
96
164
  */
97
- density?: TaskDensity;
165
+ actionWidth?: number;
98
166
  /**
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).
167
+ * `comfortable` (the default) gives every row a 44px first-line BAND the tap target a
168
+ * finger needs, and what the leading control centres in; `dense` drops to 32px for a
169
+ * pointer-driven register of TEXT rows (an inline editor carries its own 40px control band,
170
+ * which a 32px row cannot contain). Density belongs to the SURFACE, so
171
+ * it is set once here and inherited by nested lists.
103
172
  */
104
- fieldsWidth?: number;
173
+ density?: TaskDensity;
105
174
  accessibilityLabel?: string;
106
175
  }
107
176
 
108
177
  /**
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.
178
+ * The list — owns geometry (the two gutters, the indent, density) for every descendant. Nest one
179
+ * inside a `TaskItem` and its rows become that task's subtasks, indented one step.
111
180
  */
112
181
  export function TaskList(props: TaskListProps) {
113
182
  const parent = useContext(TaskListContext);
114
183
  const isNested = parent.depth > 0;
115
184
  const controlWidth = props.controlWidth ?? parent.controlWidth;
185
+ const actionWidth = props.actionWidth ?? parent.actionWidth;
116
186
  const density = props.density ?? parent.density;
117
- const fieldsWidth = props.fieldsWidth ?? parent.fieldsWidth;
118
187
  return (
119
- <TaskListContext.Provider value={{ controlWidth, depth: parent.depth + 1, density, fieldsWidth }}>
188
+ <TaskListContext.Provider value={{ controlWidth, actionWidth, depth: parent.depth + 1, density }}>
120
189
  <View
121
190
  style={[
122
191
  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 },
192
+ // A nested list hands the PARENT's right gutter BACK, because its own rows are about to
193
+ // reserve one of their own: without it the ⋯ column would walk one gutter inward per
194
+ // level, and a subtask's menu would sit left of its parent's instead of under it. The
195
+ // LEFT gutter is not given back — there, stepping inward IS the indent.
196
+ isNested && [styles.nestedList, { marginRight: -taskGutter(parent.actionWidth) }],
125
197
  ]}
126
198
  role="list"
127
199
  accessibilityLabel={props.accessibilityLabel}
@@ -133,22 +205,34 @@ export function TaskList(props: TaskListProps) {
133
205
  }
134
206
 
135
207
  /**
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.
208
+ * One task. Its two pinned controls — `TaskStatus`, `TaskActions` — sit in the gutters, out of
209
+ * the flow; everything else is a full-width LINE in the content box between them (`TaskTitle`
210
+ * first, then any `TaskCaption`, `TaskSubRow`, `TaskDetail` and nested `TaskList`), stacking in
211
+ * the order it was written. Order in JSX is order on screen; nothing inspects child types.
139
212
  */
140
213
  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.
214
+ const { controlWidth, actionWidth } = useContext(TaskListContext);
215
+ // BOTH controls are taken OUT of the wrapping flow and pinned to a gutter the item RESERVES —
216
+ // the same `taskGutter` on each edge so the row's content, title through nested list, shares
217
+ // ONE box between them. Anything that hangs therefore lands under the title instead of under
218
+ // the checkbox (a wrapped block read as a row of its own, belonging to nothing) and stops where
219
+ // the title stops instead of running on under the ⋯ (the first line and every line beneath it
220
+ // ended at two different x's, which is what a self-sizing menu in the flow cost).
146
221
  //
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.
222
+ // Pinned means the control's centre is a CONSTANT (half the band), so the row's FIRST LINE
223
+ // has to be that band or the two disagree the moment anything hangs below. Claiming it is
224
+ // `TaskTitle`'s job — the FIRST line's, and only its. Boxing EVERY slot at the band was the
225
+ // earlier attempt and it boxed the caption too, centring that line in its own 44 and pushing
226
+ // it half a band below the words it describes; hanging off the title's band costs the caption
227
+ // the band's bottom slack (12px) instead, the same for a text title and an editor one. The
228
+ // item itself carries the band only as its MINIMUM, which is what gives a one-line row its
229
+ // tap target.
149
230
  return (
150
231
  <View
151
- style={[styles.item, { minHeight: useRowMinHeight(), paddingLeft: controlWidth + ROW_GAP }]}
232
+ style={[
233
+ styles.item,
234
+ { minHeight: useRowBand(), paddingLeft: taskGutter(controlWidth), paddingRight: taskGutter(actionWidth) },
235
+ ]}
152
236
  role="listitem"
153
237
  >
154
238
  {props.children}
@@ -156,52 +240,67 @@ export function TaskItem(props: { children: ReactNode }) {
156
240
  );
157
241
  }
158
242
 
159
- function useRowMinHeight(): number {
243
+ /** The row's FIRST-LINE band, and so also its minimum height — with nothing hanging beneath
244
+ * it, the first line IS the row. */
245
+ function useRowBand(): number {
160
246
  const { density } = useContext(TaskListContext);
161
- return density === "dense" ? DENSE_ROW : TOUCH_TARGET;
247
+ return TASK_ROW_BAND[density];
162
248
  }
163
249
 
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. */
250
+ /** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned into the list's
251
+ * LEFT gutter (`controlWidth`), the exact mirror of `TaskActions` on the right, so every title
252
+ * starts on the same edge. That gutter is also the INDENT: what hangs under a task steps right
253
+ * by one of it, which is the only thing that says "this belongs to the task above", at any
254
+ * depth. */
166
255
  export function TaskStatus(props: { children: ReactNode }) {
167
256
  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.
257
+ // Pinned to the gutter and sized to the BAND the row's first line, which `TaskTitle`
258
+ // claims — so the control centres on the title's first line however tall the row grows
259
+ // underneath it, and every row's control sits at the same y as its own title's words.
170
260
  return (
171
- <View style={[styles.status, { width: controlWidth, height: useRowMinHeight() }]}>
261
+ <View style={[styles.status, { width: controlWidth, height: useRowBand() }]}>
172
262
  {props.children}
173
263
  </View>
174
264
  );
175
265
  }
176
266
 
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
- }
267
+ type TaskTitleProps =
268
+ // The title TEXT. `struck` belongs to this shape alone a node carries its own treatment
269
+ // (an inline editor has its own `struck`), so typing it `never` there keeps the prop from
270
+ // being passed where nothing would read it.
271
+ | { children: string; struck?: boolean }
272
+ | { children: ReactNode; struck?: never };
184
273
 
185
274
  /**
186
- * The descriptor clusterdue date, assignee, chips, counts. Sits inline after the title
187
- * while the row has room and takes its own line when it does not.
275
+ * The task's identityan inline editor (`variant="cell"`), or the title text itself.
276
+ *
277
+ * Pass a STRING and the title renders on the cell inset with the band's slack above and below
278
+ * it, so it lines up with the editable titles around it AND a title that wraps keeps its first
279
+ * line beside the control; `struck` strikes + mutes it for a finished task. Pass a node when
280
+ * the title is an editor or carries more than words — it centres in the band.
188
281
  *
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.
282
+ * Spans the row's content box gutter to gutter, the same width as every line hung beneath it,
283
+ * so a long title wraps inside the box instead of running under the ⋯. And it claims the row's
284
+ * BAND, which is what keeps both gutter controls beside the title instead of sagging toward a
285
+ * caption below it.
191
286
  */
192
- export function TaskFields(props: { children: ReactNode }) {
193
- const { fieldsWidth } = useContext(TaskListContext);
287
+ export function TaskTitle(props: TaskTitleProps) {
288
+ const band = useRowBand();
289
+ if (typeof props.children === "string") {
290
+ return (
291
+ <View style={[styles.title, { minHeight: band, paddingHorizontal: TASK_TEXT_INSET, paddingVertical: taskTitleSlack(band) }]}>
292
+ <Text
293
+ size="sm"
294
+ color={props.struck ? "muted" : "default"}
295
+ decoration={props.struck ? "lineThrough" : undefined}
296
+ >
297
+ {props.children}
298
+ </Text>
299
+ </View>
300
+ );
301
+ }
194
302
  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>
303
+ <View style={[styles.title, { minHeight: band }]}>{props.children}</View>
205
304
  );
206
305
  }
207
306
 
@@ -209,11 +308,10 @@ export function TaskFields(props: { children: ReactNode }) {
209
308
  * A second line under the title — what this task's STATE is, in words: "Needs: Carrier,
210
309
  * Vehicle plate", "Waiting on the yard", "3 of 5 papers received".
211
310
  *
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.
311
+ * Deliberately NOT a `TaskSubRow`. A sub-row is a NAMED value the reader sets; a caption is a
312
+ * SENTENCE about this row, and prose given a label reads as a field nobody can edit. It always
313
+ * takes its own line, on the title's text edge never inline, never behind a disclosure,
314
+ * because a state the reader must open to see is a state they will miss.
217
315
  */
218
316
  export function TaskCaption(props: { children: ReactNode }) {
219
317
  return (
@@ -221,16 +319,91 @@ export function TaskCaption(props: { children: ReactNode }) {
221
319
  );
222
320
  }
223
321
 
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. */
322
+ /**
323
+ * The row's actions an `ActionMenu`, an `IconButton`. A destructive item belongs behind
324
+ * the menu, never as a bare ✕ on the row, so a stray tap cannot destroy a task.
325
+ *
326
+ * Pinned into the list's RIGHT gutter (`actionWidth`), the exact mirror of `TaskStatus` on the
327
+ * left: out of the flow, flush on the item's edge, centred on the first-line band. Because the
328
+ * ITEM reserves that column rather than this box claiming it, a row without a menu ends its
329
+ * content on the same x as a row with one, and every line hung under either of them ends there
330
+ * too. A self-sizing menu in the FLOW was the alternative and it cannot hold both: the first line
331
+ * stopped where the menu began while the full-width lines beneath it ran on to the container, so
332
+ * no two right edges on the surface agreed.
333
+ */
226
334
  export function TaskActions(props: { children: ReactNode }) {
335
+ const { actionWidth } = useContext(TaskListContext);
336
+ // The band as an explicit height is the pin: the menu's centre is half the band from the row's
337
+ // top however tall the row grows, which is where the title's first line and the gutter control
338
+ // are. Centring it in the ITEM instead would sink it toward a caption or a sub-row the moment
339
+ // one appeared.
340
+ return (
341
+ <View style={[styles.actions, { width: actionWidth, height: useRowBand() }]}>
342
+ {props.children}
343
+ </View>
344
+ );
345
+ }
346
+
347
+ /**
348
+ * ONE of the task's own FIELDS, hung under the row: its NAME, then its VALUE, adjacent, indented
349
+ * one step so it reads as the task's.
350
+ *
351
+ * The sub-rows of one task share a label column sized to THEIR content — local to this task,
352
+ * never a width the list declares and never one the caller passes. A label and its value sit
353
+ * next to each other because that is how the eye pairs them; a column shared with every other
354
+ * row on the surface put a two-word name a quarter of a screen from its input, and no amount of
355
+ * alignment made that legible.
356
+ *
357
+ * The value takes the row's slack from a readable minimum: on a narrow surface it drops onto its
358
+ * own line under the label rather than compressing to a few characters, and on a wide one it
359
+ * FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
360
+ *
361
+ * **Use `TaskDetail` instead when the block is not a label/value field** — a chart, a whole
362
+ * table, a form with its own submit. (Subtasks are neither: they are a nested `TaskList`.)
363
+ */
364
+ export function TaskSubRow(props: {
365
+ /** The field's NAME, rendered muted beside its value. */
366
+ label: ReactNode;
367
+ /** The value's control — a form-variant `Inline*` editor, a `Text`. It FILLS the cell. */
368
+ children: ReactNode;
369
+ /** Persistent guidance under the value ("The password is never stored"), on the control's
370
+ * own text inset. Not a state: a fault or a warning about the ROW is a `TaskCaption`. */
371
+ description?: string;
372
+ }) {
373
+ // The sub-row's own first-line band. The row's band, unless the density's band cannot contain
374
+ // a form control (a `dense` 32 row against a 40px inline editor) — the label's slack and the
375
+ // control's centre are then BOTH taken from 40, so they still meet on one y.
376
+ const band = Math.max(useRowBand(), INLINE_CONTROL_HEIGHT);
227
377
  return (
228
- <View style={styles.actions}>{props.children}</View>
378
+ <View style={styles.subRow}>
379
+ <View
380
+ style={[
381
+ styles.subLabel,
382
+ // Symmetric slack, not centring — a field name long enough to WRAP keeps its first
383
+ // line on the control beside it, the same law `TaskTitle` follows against the ring.
384
+ { paddingVertical: taskTitleSlack(band) },
385
+ ]}
386
+ >
387
+ <Text size="sm" color="muted">{props.label}</Text>
388
+ </View>
389
+ <View style={styles.subValue}>
390
+ <View style={[styles.subControl, { minHeight: band }]}>{props.children}</View>
391
+ {props.description !== undefined ? (
392
+ <Text size="xs" color="muted" style={styles.subDescription}>{props.description}</Text>
393
+ ) : null}
394
+ </View>
395
+ </View>
229
396
  );
230
397
  }
231
398
 
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. */
399
+ /**
400
+ * A block beneath the row, indented to the title's text edge a chart, a table, a form with
401
+ * its own submit, a drill-down. Render it only while open.
402
+ *
403
+ * FREE-FORM content only: a label/value FIELD is a `TaskSubRow`. It is the one thing hung under
404
+ * a task that does NOT take the indent, because it has no label to put there — a rule down its
405
+ * left edge is what ties it to the row instead.
406
+ */
234
407
  export function TaskDetail(props: { children: ReactNode }) {
235
408
  return (
236
409
  <View style={[styles.detail, { marginLeft: TASK_TEXT_INSET }]}>{props.children}</View>
@@ -241,47 +414,86 @@ const styles = StyleSheet.create({
241
414
  // No gap: every row already carries the density's min-height, so the rhythm is the ROW,
242
415
  // not the space between rows. A gap on top of it made the list read as loose pairs.
243
416
  list: { gap: 0 },
417
+ // A nested list is a child-step list: its own rows take the indent, so it needs only its
418
+ // own line and a hair of air. `flexGrow` is what makes the give-back a give-back — a 100% basis
419
+ // pulled left by a negative margin would just occupy less of its line, so the list has to grow
420
+ // back INTO the parent's gutter to end where the parent's own edge is.
421
+ nestedList: { flexBasis: "100%", flexGrow: 1, marginTop: 2, marginBottom: 2 },
244
422
  item: {
423
+ // Both gutter controls position against THIS box — against its PADDING box, which is the
424
+ // reservation each of them sits in.
245
425
  position: "relative",
246
426
  flexDirection: "row",
247
427
  flexWrap: "wrap",
248
428
  alignItems: "center",
249
429
  // `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.
430
+ // places the LINES in the box. It has slack to distribute only on a row whose content is
431
+ // SHORTER than the band one with no title, since a title claims the band and centring
432
+ // there keeps that row's content on the gutter control's centre. It is NOT what aligns a
433
+ // normal row: a centred block of lines moves with whatever hangs below it, which is
434
+ // exactly how the control came to sag under its own title.
253
435
  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.
436
+ columnGap: TASK_ROW_GAP,
437
+ // Lines inside ONE task are the same thought (title → caption → fields), so they sit
438
+ // tight; `gap` would have applied the 12px column rhythm vertically too. The first line is
439
+ // the exception by construction: it is the BAND, so what follows starts under the band's
440
+ // bottom edge, not under the title's last word.
257
441
  rowGap: 2,
258
442
  },
259
443
  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,
444
+ // The mirror of `status`, on the item's own right edge an absolute box positions against the
445
+ // item's PADDING box, so `right: 0` puts it in the gutter the item reserved, exactly as
446
+ // `left: 0` does. `flex-end` seats a NARROWER control (a `size="sm"` ⋯ in a 28 gutter) flush on
447
+ // that edge, as `flex-start` does on the left.
448
+ actions: { position: "absolute", right: 0, top: 0, justifyContent: "center", alignItems: "flex-end" },
449
+ // The title IS the content box — one line spanning gutter to gutter, like every line hung
450
+ // beneath it. It claimed a readable MINIMUM while the ⋯ shared its line, which is what pushed
451
+ // the menu onto a line of its own rather than letting it crush the words; with the menu pinned
452
+ // in a gutter there is nothing left on the line to wrap against, and a minimum could then only
453
+ // overflow a narrow surface. The BAND arrives as an inline `minHeight`, and `center` seats a
454
+ // shorter child (an inline editor is 40 in a 44 band) on the band's centre — where the gutter
455
+ // control is.
456
+ title: { flexBasis: "100%", minWidth: 0, justifyContent: "center" },
457
+ // `flexBasis: "100%"` is the whole trick: the caption always breaks to its own line.
458
+ caption: { flexBasis: "100%" },
459
+ // Its own line (the caption's trick), then a NAME and a VALUE side by side on the same 12px
460
+ // rhythm the row uses — and the same wrap: too narrow to seat both and the value drops to its
461
+ // own line under the label.
462
+ subRow: { flexBasis: "100%", flexDirection: "row", flexWrap: "wrap", columnGap: TASK_ROW_GAP },
463
+ // `flexBasis: auto` — the label is sized by its OWN words, clamped to the shared band the
464
+ // floor and ceiling define. `flexShrink` lets it give way before it overflows a container
465
+ // narrower than the floor. The inset puts its WORDS on the TITLE's x, not one step in: the
466
+ // control gutter already says which rows are tasks and which are their fields, so an indent
467
+ // on top of it only breaks the single left edge every line on the surface otherwise shares.
468
+ subLabel: {
469
+ flexGrow: 0,
264
470
  flexShrink: 1,
265
- flexBasis: TITLE_MIN_WIDTH,
266
- minWidth: TITLE_MIN_WIDTH,
267
- justifyContent: "center",
471
+ minWidth: SUB_LABEL_MIN,
472
+ maxWidth: SUB_LABEL_MAX,
473
+ paddingLeft: TASK_TEXT_INSET,
268
474
  },
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.
475
+ // FILLS the rest of the row, exactly as `DetailRow`'s value column does — a sub-row is the
476
+ // same label/value shape one surface down, and a value that stopped short of the container
477
+ // left the row reading as a fragment floating under a full-width one. A control too wide for
478
+ // its content is that CONTROL's business (and `DetailRow` would have the same complaint), not
479
+ // a reason for this row to end early.
480
+ subValue: { flexGrow: 1, flexShrink: 1, flexBasis: SUB_VALUE_MIN, minWidth: 0 },
481
+ // The control CENTRES in the sub-row's band the label's first line is pinned to the same
482
+ // centre — and stretches to the cell's full width.
483
+ subControl: { justifyContent: "center" },
484
+ // The control's own text inset, so the guidance lines up with the value it describes rather
485
+ // than with the editor's invisible box.
486
+ subDescription: { paddingLeft: TASK_TEXT_INSET, marginTop: 2 },
487
+ // A full line like the rest, except that its inset arrives as a MARGIN (the rule has to sit on
488
+ // the title's text edge, so it cannot be padding). A 100% basis plus that margin is 9px wider
489
+ // than the line, so `flexShrink` gives the 9 back and the block ends on the content edge with
490
+ // everything else — without it the one line that carries a rule was the one line that ran into
491
+ // the ⋯ gutter.
492
+ detail: {
493
+ flexBasis: "100%",
280
494
  flexShrink: 1,
281
- minWidth: 0,
495
+ borderLeftWidth: 1,
496
+ borderLeftColor: colors.border,
497
+ paddingLeft: TASK_ROW_GAP,
282
498
  },
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
499
  });