@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/MIGRATION.md +65 -0
- package/docs/catalog.md +78 -26
- package/docs/data_entry.md +55 -21
- package/docs/templates.md +17 -17
- package/examples/tpl_item_list.tsx +26 -26
- package/examples/tpl_record.tsx +38 -38
- package/package.json +1 -1
- package/src/detail_row.tsx +7 -1
- package/src/file_grid.tsx +5 -0
- package/src/file_thumbnail.tsx +27 -10
- package/src/file_thumbnail_grid.tsx +4 -0
- package/src/linked_record_box.tsx +30 -13
- package/src/task.tsx +347 -135
- package/src/task_metrics.test.ts +48 -0
- package/src/task_metrics.ts +55 -0
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
|
|
10
|
+
* The TASK compound — a list of things to be done, at any depth.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* **
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
* **
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
/**
|
|
80
|
+
/** The LEFT gutter's control — the leading ring or checkbox. One indent step is its gutter. */
|
|
49
81
|
controlWidth: number;
|
|
50
|
-
/**
|
|
51
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
76
|
-
* adds the same, so it lines up with the WORDS rather than with the editor's invisible
|
|
77
|
-
*
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
94
|
-
* `
|
|
95
|
-
* it is
|
|
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
|
-
|
|
165
|
+
actionWidth?: number;
|
|
98
166
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
173
|
+
density?: TaskDensity;
|
|
105
174
|
accessibilityLabel?: string;
|
|
106
175
|
}
|
|
107
176
|
|
|
108
177
|
/**
|
|
109
|
-
* The list — owns geometry (
|
|
110
|
-
* inside a `TaskItem` and its rows become that task's subtasks, indented one
|
|
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
|
|
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
|
|
124
|
-
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// that
|
|
145
|
-
//
|
|
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
|
-
//
|
|
148
|
-
//
|
|
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={[
|
|
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
|
-
|
|
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
|
|
247
|
+
return TASK_ROW_BAND[density];
|
|
162
248
|
}
|
|
163
249
|
|
|
164
|
-
/** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned
|
|
165
|
-
*
|
|
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
|
|
169
|
-
// tall the row grows
|
|
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:
|
|
261
|
+
<View style={[styles.status, { width: controlWidth, height: useRowBand() }]}>
|
|
172
262
|
{props.children}
|
|
173
263
|
</View>
|
|
174
264
|
);
|
|
175
265
|
}
|
|
176
266
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
187
|
-
*
|
|
275
|
+
* The task's identity — an 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
|
-
*
|
|
190
|
-
*
|
|
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
|
|
193
|
-
const
|
|
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
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
/**
|
|
225
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
233
|
-
*
|
|
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
|
|
251
|
-
// the
|
|
252
|
-
//
|
|
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:
|
|
255
|
-
// Lines inside ONE task are the same thought (title → caption →
|
|
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
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
471
|
+
minWidth: SUB_LABEL_MIN,
|
|
472
|
+
maxWidth: SUB_LABEL_MAX,
|
|
473
|
+
paddingLeft: TASK_TEXT_INSET,
|
|
268
474
|
},
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
});
|