@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/AGENTS.md +13 -10
- package/MIGRATION.md +78 -0
- package/docs/catalog.md +103 -141
- package/docs/data_entry.md +57 -87
- package/docs/templates.md +95 -57
- package/examples/tpl_item_list.tsx +77 -36
- package/examples/tpl_record.tsx +372 -460
- package/package.json +3 -3
- package/src/checklist.tsx +353 -0
- package/src/comments_button.tsx +108 -0
- package/src/control_surface.ts +13 -0
- package/src/detail_row.tsx +2 -2
- package/src/field_annotations.tsx +1 -1
- package/src/icon_button.tsx +17 -1
- package/src/locale.tsx +12 -1
- package/src/stepper.tsx +107 -18
- package/src/stepper_layout.ts +21 -1
- package/src/table.tsx +101 -11
- package/src/pipeline.tsx +0 -231
- package/src/task.tsx +0 -518
- package/src/task_metrics.ts +0 -55
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
|
-
});
|
package/src/task_metrics.ts
DELETED
|
@@ -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
|
-
}
|