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