@lotics/ui 16.2.0 → 17.0.1
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 +47 -0
- package/docs/catalog.md +38 -53
- package/docs/data_entry.md +54 -26
- package/docs/templates.md +7 -8
- package/examples/tpl_item_list.tsx +47 -44
- package/examples/tpl_record.tsx +70 -79
- package/examples/tpl_task_board.tsx +7 -1
- package/package.json +3 -3
- package/src/capture_row.tsx +7 -2
- package/src/check_circle.tsx +62 -18
- package/src/checkbox.tsx +7 -1
- package/src/locale.tsx +0 -24
- package/src/task.tsx +287 -0
- package/src/checklist.tsx +0 -377
package/src/task.tsx
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The TASK compound — a list of things to be done, at any depth and any layout.
|
|
7
|
+
*
|
|
8
|
+
* It replaces `Checklist`/`ChecklistRow`, which was a configuration object: eight slot props,
|
|
9
|
+
* a `trailing`/`meta` pair that was mutually exclusive BY TYPE so an author had to predict
|
|
10
|
+
* "wide surface" or "narrow" when writing the JSX, and a `subtasks` array of
|
|
11
|
+
* `{label, checked}` a child could never outgrow. Every new task layout arrived as another
|
|
12
|
+
* prop. Composition takes those decisions back:
|
|
13
|
+
*
|
|
14
|
+
* <TaskList fieldsWidth={168}>
|
|
15
|
+
* <TaskItem>
|
|
16
|
+
* <TaskStatus><CheckCircle …/></TaskStatus>
|
|
17
|
+
* <TaskTitle><InlineTextInput variant="cell" struck={done} …/></TaskTitle>
|
|
18
|
+
* <TaskFields><InlineDatePicker …/><InlineMemberSelect …/></TaskFields>
|
|
19
|
+
* <TaskActions><ActionMenu …/></TaskActions>
|
|
20
|
+
* <TaskCaption>Waiting on the signed copy</TaskCaption>
|
|
21
|
+
* <TaskDetail>…</TaskDetail>
|
|
22
|
+
* <TaskList>…</TaskList> ← subtasks ARE tasks
|
|
23
|
+
* </TaskItem>
|
|
24
|
+
* </TaskList>
|
|
25
|
+
*
|
|
26
|
+
* Two properties follow from the shape rather than from props:
|
|
27
|
+
*
|
|
28
|
+
* **A subtask is a task.** Nesting a `TaskList` inside a `TaskItem` makes the child recursive
|
|
29
|
+
* by construction, so it carries a due date, a menu, its own children — everything the parent
|
|
30
|
+
* has. The old array shape made "subtasks need X" a permanent ticket queue.
|
|
31
|
+
*
|
|
32
|
+
* **The row reflows on its own.** `TaskTitle` claims a readable minimum width; when the row
|
|
33
|
+
* cannot seat the title and the fields together, flex-wrap drops `TaskFields` onto its own
|
|
34
|
+
* line. That is intrinsic — no viewport query, no measurement — so it behaves the same in a
|
|
35
|
+
* drawer, a split pane and a phone, which a breakpoint cannot.
|
|
36
|
+
*
|
|
37
|
+
* **A value and a sentence are different things.** `TaskFields` holds the cells the reader
|
|
38
|
+
* edits and scans DOWN a column (due, assignee), sized once by the list so they stay a column;
|
|
39
|
+
* `TaskCaption` holds a sentence ABOUT the row ("Needs: Carrier, Vehicle plate") on its own
|
|
40
|
+
* line under the title. The first cut of this family merged them, which pushed prose into the
|
|
41
|
+
* value column where it aligned with nothing.
|
|
42
|
+
*
|
|
43
|
+
* There is deliberately no `note` slot. A task's free text is its title, a caption, or detail;
|
|
44
|
+
* a fourth place to write invited writing it twice.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
interface TaskListContextValue {
|
|
48
|
+
/** Width of the leading control column — nested lists and detail blocks indent by it. */
|
|
49
|
+
controlWidth: number;
|
|
50
|
+
/** Shared width of every row's field cluster, so the cells form a COLUMN. */
|
|
51
|
+
fieldsWidth?: number;
|
|
52
|
+
/** 0 at the root. Any value above 0 means "I am rendered inside a task". */
|
|
53
|
+
depth: number;
|
|
54
|
+
density: TaskDensity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TaskDensity = "comfortable" | "dense";
|
|
58
|
+
|
|
59
|
+
const TaskListContext = createContext<TaskListContextValue>({
|
|
60
|
+
controlWidth: 20,
|
|
61
|
+
depth: 0,
|
|
62
|
+
density: "comfortable",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Below this a title is unreadable, so the fields take their own line instead. */
|
|
66
|
+
const TITLE_MIN_WIDTH = 180;
|
|
67
|
+
/** The minimum comfortable tap target — a finger, not a pointer. */
|
|
68
|
+
const TOUCH_TARGET = 44;
|
|
69
|
+
/** A pointer-driven register trades the target for rows on screen. */
|
|
70
|
+
const DENSE_ROW = 32;
|
|
71
|
+
/** Horizontal rhythm between the row's slots. */
|
|
72
|
+
const ROW_GAP = 12;
|
|
73
|
+
/**
|
|
74
|
+
* How far a `variant="cell"` control insets its own text: 1px of transparent border plus 8px
|
|
75
|
+
* of padding. Everything that hangs beneath a row — the caption, the detail, a nested list —
|
|
76
|
+
* adds the same, so it lines up with the WORDS rather than with the editor's invisible box. A
|
|
77
|
+
* plain-`Text` title carries it too, or it sits out of line with the editable titles around it.
|
|
78
|
+
*
|
|
79
|
+
* It was 8 — the padding alone, forgetting the border — which put every caption, detail block
|
|
80
|
+
* and nested list 1px left of the title it belonged to.
|
|
81
|
+
*/
|
|
82
|
+
export const TASK_TEXT_INSET = 9;
|
|
83
|
+
|
|
84
|
+
export interface TaskListProps {
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
/**
|
|
87
|
+
* Width of every row's leading control — `CheckCircle` 20 (the default), `CheckboxInput`
|
|
88
|
+
* 24. Detail blocks and nested lists indent by it so they sit on the title's text edge.
|
|
89
|
+
* Set on the ROOT list; a nested list inherits it.
|
|
90
|
+
*/
|
|
91
|
+
controlWidth?: number;
|
|
92
|
+
/**
|
|
93
|
+
* `comfortable` (the default) gives every row a 44px minimum so a finger can hit it;
|
|
94
|
+
* `dense` drops to 32px for a pointer-driven register. Density belongs to the SURFACE, so
|
|
95
|
+
* it is set once here and inherited by nested lists.
|
|
96
|
+
*/
|
|
97
|
+
density?: TaskDensity;
|
|
98
|
+
/**
|
|
99
|
+
* Fixed width for every row's `TaskFields`, so the due/assignee cells line up as a COLUMN
|
|
100
|
+
* down the list instead of drifting with each title's length. Set it once here — a row
|
|
101
|
+
* with fewer cells still reserves the width, which is what keeps the column straight.
|
|
102
|
+
* Omit it and each cluster hugs its own content (fine for a single row, jittery in a list).
|
|
103
|
+
*/
|
|
104
|
+
fieldsWidth?: number;
|
|
105
|
+
accessibilityLabel?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The list — owns geometry (control column, indent, density) for every descendant. Nest one
|
|
110
|
+
* inside a `TaskItem` and its rows become that task's subtasks, indented one column.
|
|
111
|
+
*/
|
|
112
|
+
export function TaskList(props: TaskListProps) {
|
|
113
|
+
const parent = useContext(TaskListContext);
|
|
114
|
+
const isNested = parent.depth > 0;
|
|
115
|
+
const controlWidth = props.controlWidth ?? parent.controlWidth;
|
|
116
|
+
const density = props.density ?? parent.density;
|
|
117
|
+
const fieldsWidth = props.fieldsWidth ?? parent.fieldsWidth;
|
|
118
|
+
return (
|
|
119
|
+
<TaskListContext.Provider value={{ controlWidth, depth: parent.depth + 1, density, fieldsWidth }}>
|
|
120
|
+
<View
|
|
121
|
+
style={[
|
|
122
|
+
styles.list,
|
|
123
|
+
// A nested list is a child-step list: indent it to the parent's title text edge.
|
|
124
|
+
isNested && { flexBasis: "100%", marginTop: 2, marginBottom: 2 },
|
|
125
|
+
]}
|
|
126
|
+
role="list"
|
|
127
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
128
|
+
>
|
|
129
|
+
{props.children}
|
|
130
|
+
</View>
|
|
131
|
+
</TaskListContext.Provider>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One task. Its children lay out as a WRAPPING row — `TaskStatus`, `TaskTitle`, `TaskFields`,
|
|
137
|
+
* `TaskActions` — while `TaskDetail` and a nested `TaskList` are full-width and fall beneath
|
|
138
|
+
* it. Order in JSX is order on screen; nothing inspects child types.
|
|
139
|
+
*/
|
|
140
|
+
export function TaskItem(props: { children: ReactNode }) {
|
|
141
|
+
const { controlWidth } = useContext(TaskListContext);
|
|
142
|
+
// The control is taken OUT of the wrapping flow and pinned to the left gutter, so the row's
|
|
143
|
+
// content — title, fields, caption, detail, a nested list — all share ONE column. Anything
|
|
144
|
+
// that wraps therefore lands under the title instead of under the checkbox, which is what
|
|
145
|
+
// made a wrapped field cluster read as a row of its own, belonging to nothing.
|
|
146
|
+
//
|
|
147
|
+
// The minimum tap target is the ROW's, not each slot's: boxing every slot at 44px centred
|
|
148
|
+
// the title in it and pushed the caption ~13px below the words it describes.
|
|
149
|
+
return (
|
|
150
|
+
<View
|
|
151
|
+
style={[styles.item, { minHeight: useRowMinHeight(), paddingLeft: controlWidth + ROW_GAP }]}
|
|
152
|
+
role="listitem"
|
|
153
|
+
>
|
|
154
|
+
{props.children}
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function useRowMinHeight(): number {
|
|
160
|
+
const { density } = useContext(TaskListContext);
|
|
161
|
+
return density === "dense" ? DENSE_ROW : TOUCH_TARGET;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned to the
|
|
165
|
+
* list's control column so every title starts on the same edge. */
|
|
166
|
+
export function TaskStatus(props: { children: ReactNode }) {
|
|
167
|
+
const { controlWidth } = useContext(TaskListContext);
|
|
168
|
+
// Pinned to the gutter and sized to the FIRST line, so it stays beside the title however
|
|
169
|
+
// tall the row grows underneath it.
|
|
170
|
+
return (
|
|
171
|
+
<View style={[styles.status, { width: controlWidth, height: useRowMinHeight() }]}>
|
|
172
|
+
{props.children}
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The task's identity — plain `Text` or an inline editor. Takes the row's spare width and
|
|
178
|
+
* claims a readable minimum, which is what makes `TaskFields` wrap rather than crush it. */
|
|
179
|
+
export function TaskTitle(props: { children: ReactNode }) {
|
|
180
|
+
return (
|
|
181
|
+
<View style={styles.title}>{props.children}</View>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The descriptor cluster — due date, assignee, chips, counts. Sits inline after the title
|
|
187
|
+
* while the row has room and takes its own line when it does not.
|
|
188
|
+
*
|
|
189
|
+
* Replaces the old `trailing` (wide) / `meta` (narrow) pair, which made the author pick one
|
|
190
|
+
* at authoring time and be wrong on the other surface.
|
|
191
|
+
*/
|
|
192
|
+
export function TaskFields(props: { children: ReactNode }) {
|
|
193
|
+
const { fieldsWidth } = useContext(TaskListContext);
|
|
194
|
+
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>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A second line under the title — what this task's STATE is, in words: "Needs: Carrier,
|
|
210
|
+
* Vehicle plate", "Waiting on the yard", "3 of 5 papers received".
|
|
211
|
+
*
|
|
212
|
+
* Deliberately NOT part of `TaskFields`. A field is a VALUE the reader edits and scans down a
|
|
213
|
+
* column; a caption is a SENTENCE about this row. Merging them (as the first cut of this
|
|
214
|
+
* family did) pushes prose into the value column, where it aligns with nothing and squeezes
|
|
215
|
+
* the cells. It always takes its own line, on the title's text edge — never inline, never
|
|
216
|
+
* behind a disclosure, because a state the reader must open to see is a state they will miss.
|
|
217
|
+
*/
|
|
218
|
+
export function TaskCaption(props: { children: ReactNode }) {
|
|
219
|
+
return (
|
|
220
|
+
<View style={[styles.caption, { paddingLeft: TASK_TEXT_INSET }]}>{props.children}</View>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The row's actions — an `ActionMenu`, an `IconButton`. A destructive item belongs behind
|
|
225
|
+
* the menu, never as a bare ✕ on the row, so a stray tap cannot destroy a task. */
|
|
226
|
+
export function TaskActions(props: { children: ReactNode }) {
|
|
227
|
+
return (
|
|
228
|
+
<View style={styles.actions}>{props.children}</View>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A block beneath the row, indented to the title's text edge — an inline editor, a
|
|
233
|
+
* drill-down, the task's own fields. Render it only while open. */
|
|
234
|
+
export function TaskDetail(props: { children: ReactNode }) {
|
|
235
|
+
return (
|
|
236
|
+
<View style={[styles.detail, { marginLeft: TASK_TEXT_INSET }]}>{props.children}</View>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const styles = StyleSheet.create({
|
|
241
|
+
// No gap: every row already carries the density's min-height, so the rhythm is the ROW,
|
|
242
|
+
// not the space between rows. A gap on top of it made the list read as loose pairs.
|
|
243
|
+
list: { gap: 0 },
|
|
244
|
+
item: {
|
|
245
|
+
position: "relative",
|
|
246
|
+
flexDirection: "row",
|
|
247
|
+
flexWrap: "wrap",
|
|
248
|
+
alignItems: "center",
|
|
249
|
+
// `align-items` centres within a LINE; with `flex-wrap: wrap` it is `align-content` that
|
|
250
|
+
// places the lines in the box. Without it a short row (a plain-text subtask, 20px) sat at
|
|
251
|
+
// the top of its 44px target while the gutter control centred — the control read as
|
|
252
|
+
// belonging to the row below.
|
|
253
|
+
alignContent: "center",
|
|
254
|
+
columnGap: ROW_GAP,
|
|
255
|
+
// Lines inside ONE task are the same thought (title → caption → detail), so they sit
|
|
256
|
+
// tight; `gap` would have applied the 12px column rhythm vertically too.
|
|
257
|
+
rowGap: 2,
|
|
258
|
+
},
|
|
259
|
+
status: { position: "absolute", left: 0, top: 0, justifyContent: "center", alignItems: "flex-start" },
|
|
260
|
+
// flexBasis at the minimum (not 0) is what drives the wrap: once title + fields cannot
|
|
261
|
+
// both fit, the fields — which never shrink — are pushed to the next line.
|
|
262
|
+
title: {
|
|
263
|
+
flexGrow: 1,
|
|
264
|
+
flexShrink: 1,
|
|
265
|
+
flexBasis: TITLE_MIN_WIDTH,
|
|
266
|
+
minWidth: TITLE_MIN_WIDTH,
|
|
267
|
+
justifyContent: "center",
|
|
268
|
+
},
|
|
269
|
+
fields: {
|
|
270
|
+
flexDirection: "row",
|
|
271
|
+
alignItems: "center",
|
|
272
|
+
justifyContent: "flex-end",
|
|
273
|
+
// NOWRAP: the cluster is a ROW of cells, and it wraps as a UNIT under the title. Letting
|
|
274
|
+
// it wrap internally split the cells across two lines with the ⋯ stranded between them.
|
|
275
|
+
// Too little room means the cells compress, never that they stack.
|
|
276
|
+
flexWrap: "nowrap",
|
|
277
|
+
gap: 8,
|
|
278
|
+
// Shrinkable, so a narrow container compresses the cells instead of shoving the ⋯ onto a
|
|
279
|
+
// line of its own. `fieldsWidth` still pins the column when a list wants one.
|
|
280
|
+
flexShrink: 1,
|
|
281
|
+
minWidth: 0,
|
|
282
|
+
},
|
|
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
|
+
});
|
package/src/checklist.tsx
DELETED
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
import { createContext, useCallback, useContext, useId, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
|
|
3
|
-
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
4
|
-
import { ActivityIndicator } from "./activity_indicator";
|
|
5
|
-
import { CheckboxInput } from "./checkbox_input";
|
|
6
|
-
import { colors } from "./colors";
|
|
7
|
-
import { CONTROL_RADIUS, CONTROL_TRANSITION } from "./control_surface";
|
|
8
|
-
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
9
|
-
import { Icon } from "./icon";
|
|
10
|
-
import { IconButton } from "./icon_button";
|
|
11
|
-
import { useInlineEdit, useInlineEditFocusRestore } from "./inline_edit";
|
|
12
|
-
import { useLoticsLocale } from "./locale";
|
|
13
|
-
import { Text } from "./text";
|
|
14
|
-
import { TextInputField } from "./text_input_field";
|
|
15
|
-
|
|
16
|
-
// The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
|
|
17
|
-
// (row height, control/title alignment, the note/meta/subtask/expansion
|
|
18
|
-
// indent, one trailing column width) while the CONTENT stays composed: tasks (a
|
|
19
|
-
// `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
|
|
20
|
-
// rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
|
|
21
|
-
// readiness `meta` line and a fill editor in `expansion`) are the same
|
|
22
|
-
// anatomy. `note` is the row's own free-text second line (see
|
|
23
|
-
// {@link ChecklistRowNote}) and `subtasks` its collapsible CHILD steps (see
|
|
24
|
-
// {@link ChecklistRowSubtasks}). `menu` carries the row's ⋯ options (Delete lives
|
|
25
|
-
// BEHIND the menu — indirection that prevents
|
|
26
|
-
// accidental destructive taps). Suggested tasks are NOT rows: offer the record
|
|
27
|
-
// type's commons as `SuggestionChip`s under the list (tap = materialize,
|
|
28
|
-
// ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
|
|
29
|
-
// list as its add affordance. There is deliberately NO monolithic Task
|
|
30
|
-
// component — richer task-management rows (expand affordances, tag/clip meta,
|
|
31
|
-
// board cards) compose their own anatomy directly.
|
|
32
|
-
//
|
|
33
|
-
// <Checklist trailingWidth={140}>
|
|
34
|
-
// <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
|
|
35
|
-
// note={{ value: task.note, onSave: (v) => saveNote(task.id, v) }}
|
|
36
|
-
// subtasks={{ items: task.steps.map((s) => ({ key: s.id, label: s.label,
|
|
37
|
-
// checked: s.done, onToggle: (on) => toggleStep(s.id, on) })) }}
|
|
38
|
-
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
39
|
-
// accessibilityLabel: "Task options: …" }}>
|
|
40
|
-
// <InlineTextInput variant="cell" struck={done} … />
|
|
41
|
-
// </ChecklistRow>
|
|
42
|
-
// <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
|
|
43
|
-
// <CaptureRow … />
|
|
44
|
-
// </Checklist>
|
|
45
|
-
|
|
46
|
-
interface ChecklistContextValue {
|
|
47
|
-
trailingWidth?: number;
|
|
48
|
-
controlWidth: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const ChecklistContext = createContext<ChecklistContextValue>({ controlWidth: 20 });
|
|
52
|
-
|
|
53
|
-
export interface ChecklistProps {
|
|
54
|
-
/** Fixed width of every row's trailing cell (an assignee select) — set it
|
|
55
|
-
* once so the column lines up; omit when rows carry no trailing. */
|
|
56
|
-
trailingWidth?: number;
|
|
57
|
-
/** Width of every row's leading control — `CheckCircle` 20 (the default),
|
|
58
|
-
* `CheckboxInput` 24. The `note`/`meta`/`subtasks`/`expansion` indent
|
|
59
|
-
* derives from it, so every second line and child row sits on the title's
|
|
60
|
-
* text edge. */
|
|
61
|
-
controlWidth?: number;
|
|
62
|
-
children: ReactNode;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function Checklist(props: ChecklistProps) {
|
|
66
|
-
const { trailingWidth, controlWidth = 20, children } = props;
|
|
67
|
-
return (
|
|
68
|
-
<ChecklistContext.Provider value={{ trailingWidth, controlWidth }}>
|
|
69
|
-
<View style={styles.list}>{children}</View>
|
|
70
|
-
</ChecklistContext.Provider>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The row's own NOTE — the free-text second line a checklist item needs
|
|
75
|
-
* ("waiting on the signed copy") without promoting it to a record. One
|
|
76
|
-
* contract, so the muted line, the tap-to-edit, the removal, and the empty-row
|
|
77
|
-
* add affordance behave identically in every app. */
|
|
78
|
-
export interface ChecklistRowNote {
|
|
79
|
-
/** The note text. `""` = the row has NO note: the line is absent and a small
|
|
80
|
-
* add affordance sits on the row instead. */
|
|
81
|
-
value: string;
|
|
82
|
-
/** Persist the edited note. An EMPTY value REMOVES the note (`onSave("")`) —
|
|
83
|
-
* clearing the text IS the delete gesture, so the note needs no ⋯ entry of
|
|
84
|
-
* its own. Whitespace-only input is trimmed to `""` first. May be async: the
|
|
85
|
-
* line shows a saving spinner and surfaces a thrown error inline, staying
|
|
86
|
-
* open so the typing isn't lost. */
|
|
87
|
-
onSave: (next: string) => void | Promise<void>;
|
|
88
|
-
/** Accessible name (and tooltip) of the add affordance; defaults to the
|
|
89
|
-
* locale's `checklist.addNote`. Name the ROW when the list is long
|
|
90
|
-
* ("Add note: Book the carrier"). */
|
|
91
|
-
addLabel?: string;
|
|
92
|
-
/** Accessible name of the note line's press target; defaults to the locale's
|
|
93
|
-
* `checklist.editNote`, which WRAPS the note text so the line is announced
|
|
94
|
-
* with it ("Edit note: waiting on the signed copy"). */
|
|
95
|
-
editLabel?: string;
|
|
96
|
-
/** The editor's placeholder; defaults to the locale's
|
|
97
|
-
* `checklist.notePlaceholder`. */
|
|
98
|
-
placeholder?: string;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** ONE child step under a checklist row: a checkbox and a short label, and
|
|
102
|
-
* deliberately NOTHING else — no note, no assignee, no ⋯ menu. A step that
|
|
103
|
-
* needs any of those is a TASK, so promote it to a row of its own rather than
|
|
104
|
-
* growing a second row anatomy inside the first. */
|
|
105
|
-
export interface ChecklistSubtask {
|
|
106
|
-
/** Stable identity — the React key, and what the caller patches on toggle. */
|
|
107
|
-
key: string;
|
|
108
|
-
/** The step's title AND its checkbox's accessible name. Keep it to a phrase:
|
|
109
|
-
* a child row has no second line to overflow into. */
|
|
110
|
-
label: string;
|
|
111
|
-
checked: boolean;
|
|
112
|
-
onToggle: (checked: boolean) => void;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** The row's collapsible CHILD STEPS — the "3 things this one task is made of"
|
|
116
|
-
* that apps otherwise hand-roll as indented rows with a bespoke chevron. One
|
|
117
|
-
* contract, so the expander, the remaining-count, the indent, and the
|
|
118
|
-
* announcement behave identically in every app. */
|
|
119
|
-
export interface ChecklistRowSubtasks {
|
|
120
|
-
/** The child steps — see {@link ChecklistSubtask}. EMPTY renders nothing at
|
|
121
|
-
* all (not even the expander): an affordance that opens onto nothing is
|
|
122
|
-
* noise, so a row with no steps is indistinguishable from one that can't
|
|
123
|
-
* have them. */
|
|
124
|
-
items: ChecklistSubtask[];
|
|
125
|
-
/** CONTROLLED expansion. Omit it and the ROW owns the state: open on mount
|
|
126
|
-
* while ANY step is unchecked, closed when they're all done — and the
|
|
127
|
-
* user's toggle wins from then on, so checking the last step never yanks
|
|
128
|
-
* the list shut under the pointer. */
|
|
129
|
-
expanded?: boolean;
|
|
130
|
-
/** Fires with the NEXT state on every expander press — in BOTH modes, so an
|
|
131
|
-
* uncontrolled row can still persist "the user opened this one". */
|
|
132
|
-
onToggleExpanded?: (open: boolean) => void;
|
|
133
|
-
/** Accessible name of the expander, given the count of UNCHECKED steps;
|
|
134
|
-
* defaults to the locale's `checklist.subtasks`, which takes the same
|
|
135
|
-
* argument. Name the ROW when the list is long ("Steps · Book the carrier:
|
|
136
|
-
* 2 left"). The open/closed STATE is NOT its job — `aria-expanded` carries
|
|
137
|
-
* that, and a name that flips on toggle re-announces the whole control. */
|
|
138
|
-
countLabel?: (missing: number) => string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface ChecklistRowBaseProps {
|
|
142
|
-
/** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
|
|
143
|
-
control: ReactNode;
|
|
144
|
-
/** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
|
|
145
|
-
children: ReactNode;
|
|
146
|
-
/** The row's free-text second line — see {@link ChecklistRowNote}. It sits
|
|
147
|
-
* directly under the title (above `meta`) on the title's text edge, ALWAYS
|
|
148
|
-
* visible — never behind `expansion`, because a note the reader must open to
|
|
149
|
-
* see gets written twice. Composes with `trailing` AND `meta`: the note is a
|
|
150
|
-
* second LINE, not a replacement for either. */
|
|
151
|
-
note?: ChecklistRowNote;
|
|
152
|
-
/** The row's ⋯ options menu (destructive items last, `danger: true`) —
|
|
153
|
-
* Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
|
|
154
|
-
* destroy a task. Name the task in the label ("Task options: Book the
|
|
155
|
-
* carrier"). The ⋯ column aligns only when every row in the list carries
|
|
156
|
-
* a menu — keep its presence uniform per list. */
|
|
157
|
-
menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
|
|
158
|
-
/** The row's collapsible CHILD STEPS — see {@link ChecklistRowSubtasks}. The
|
|
159
|
-
* parent grows a chevron expander (with the unchecked count beside it) on
|
|
160
|
-
* the TRAILING side of the title, never a leading one: the leading slot is
|
|
161
|
-
* `control`, and a chevron there would push the title off the text edge on
|
|
162
|
-
* the rows that have no steps. */
|
|
163
|
-
subtasks?: ChecklistRowSubtasks;
|
|
164
|
-
/** A BLOCK slot under the row (below `meta` and the open `subtasks`),
|
|
165
|
-
* indented to the title's text edge — a transient inline fill editor, a
|
|
166
|
-
* drill-down. Render it only while open; the list's geometry (the indent)
|
|
167
|
-
* is owned here. */
|
|
168
|
-
expansion?: ReactNode;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
|
|
172
|
-
export type ChecklistRowProps = ChecklistRowBaseProps &
|
|
173
|
-
(
|
|
174
|
-
| {
|
|
175
|
-
/** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
|
|
176
|
-
trailing?: ReactNode;
|
|
177
|
-
meta?: never;
|
|
178
|
-
}
|
|
179
|
-
| {
|
|
180
|
-
/** SECOND line under the title (indented past the ring) for a NARROW
|
|
181
|
-
* surface — a drawer or popover checklist: put the assignee/due
|
|
182
|
-
* editors here so the title keeps the full width and stays readable. */
|
|
183
|
-
meta?: ReactNode;
|
|
184
|
-
trailing?: never;
|
|
185
|
-
}
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
/** One checklist line: control · title (flex) · the subtask expander · the note
|
|
189
|
-
* affordance · the aligned trailing cell · ⋯, with the optional `note` line
|
|
190
|
-
* below it, an optional indented `meta` line for narrow surfaces, the expanded
|
|
191
|
-
* `subtasks`, and an optional `expansion` block under that (all of them sit on
|
|
192
|
-
* the title's text edge). */
|
|
193
|
-
export function ChecklistRow(props: ChecklistRowProps) {
|
|
194
|
-
const { control, children, trailing, menu, meta, note, subtasks, expansion } = props;
|
|
195
|
-
const { trailingWidth, controlWidth } = useContext(ChecklistContext);
|
|
196
|
-
const labels = useLoticsLocale().checklist;
|
|
197
|
-
// The title names the subtask GROUP (`aria-labelledby`), so a screen reader
|
|
198
|
-
// announces the children under the parent they belong to instead of as a run
|
|
199
|
-
// of loose checkboxes.
|
|
200
|
-
const titleId = useId();
|
|
201
|
-
const subtaskItems = subtasks?.items;
|
|
202
|
-
const missing = subtaskItems == null ? 0 : subtaskItems.reduce((n, item) => (item.checked ? n : n + 1), 0);
|
|
203
|
-
// Uncontrolled expansion is seeded ONCE (lazily): a row with work left opens,
|
|
204
|
-
// and the user owns it from then on. Re-deriving it every render would slam
|
|
205
|
-
// the panel shut the instant the last step is checked — mid-interaction.
|
|
206
|
-
const [selfExpanded, setSelfExpanded] = useState(() => subtaskItems?.some((item) => !item.checked) ?? false);
|
|
207
|
-
const expanded = subtasks?.expanded ?? selfExpanded;
|
|
208
|
-
const toggleSubtasks = useCallback(() => {
|
|
209
|
-
const next = !expanded;
|
|
210
|
-
setSelfExpanded(next);
|
|
211
|
-
subtasks?.onToggleExpanded?.(next);
|
|
212
|
-
}, [expanded, subtasks]);
|
|
213
|
-
// ONE editor per row, opened by either note affordance (the add button on an
|
|
214
|
-
// empty row, or a press on the note line itself). The optional call is
|
|
215
|
-
// unreachable without a `note` — nothing that opens the editor renders then.
|
|
216
|
-
const noteEdit = useInlineEdit<string>({ value: note?.value ?? "", onSave: (next) => note?.onSave(next) });
|
|
217
|
-
// Whichever resting affordance is mounted (the line, or the add button once
|
|
218
|
-
// the note is removed) takes focus back when a keyboard close drops it.
|
|
219
|
-
const noteRestRef = useRef<View>(null);
|
|
220
|
-
useInlineEditFocusRestore(
|
|
221
|
-
noteEdit.editing,
|
|
222
|
-
useCallback(() => noteRestRef.current?.focus(), []),
|
|
223
|
-
);
|
|
224
|
-
// A note is trimmed on the way out, so trailing whitespace never fakes a note
|
|
225
|
-
// and a whitespace-only entry lands as the removal (`""`).
|
|
226
|
-
const commitNote = useCallback(() => void noteEdit.commit(noteEdit.draft.trim()), [noteEdit]);
|
|
227
|
-
const onNoteKeyPress = useCallback(
|
|
228
|
-
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
229
|
-
const key = e.nativeEvent.key;
|
|
230
|
-
if (key === "Escape") noteEdit.cancel();
|
|
231
|
-
else if (key === "Enter") commitNote();
|
|
232
|
-
},
|
|
233
|
-
[noteEdit, commitNote],
|
|
234
|
-
);
|
|
235
|
-
const notePlaceholder = note?.placeholder ?? labels.notePlaceholder;
|
|
236
|
-
// Indent past the control + the row gap so note/meta/expansion align with the title.
|
|
237
|
-
const indent = controlWidth + 12;
|
|
238
|
-
return (
|
|
239
|
-
<View>
|
|
240
|
-
<View style={styles.row}>
|
|
241
|
-
{control}
|
|
242
|
-
<View style={styles.title} nativeID={titleId}>
|
|
243
|
-
{children}
|
|
244
|
-
</View>
|
|
245
|
-
{/* The DISCLOSURE: one stable name + `aria-expanded` for the state (a
|
|
246
|
-
name that flips on toggle re-announces the whole control), and the
|
|
247
|
-
remaining count beside the chevron — a collapsed row's only signal
|
|
248
|
-
that work hangs below it. */}
|
|
249
|
-
{subtaskItems != null && subtaskItems.length > 0 ? (
|
|
250
|
-
<FocusRingPressable
|
|
251
|
-
onPress={toggleSubtasks}
|
|
252
|
-
accessibilityRole="button"
|
|
253
|
-
accessibilityLabel={subtasks?.countLabel?.(missing) ?? labels.subtasks(missing)}
|
|
254
|
-
aria-expanded={expanded}
|
|
255
|
-
userSelect="none"
|
|
256
|
-
style={(state) => [styles.expander, CONTROL_TRANSITION, state.hovered ? styles.expanderHovered : null]}
|
|
257
|
-
>
|
|
258
|
-
<Icon name={expanded ? "chevron-down" : "chevron-right"} size={14} color={colors.zinc[400]} />
|
|
259
|
-
{missing > 0 ? (
|
|
260
|
-
<Text size="xs" color="muted" tabular>
|
|
261
|
-
{missing}
|
|
262
|
-
</Text>
|
|
263
|
-
) : null}
|
|
264
|
-
</FocusRingPressable>
|
|
265
|
-
) : null}
|
|
266
|
-
{/* The add affordance stands in for the missing line — always visible
|
|
267
|
-
(not hover-only: a hidden note affordance is undiscoverable, and
|
|
268
|
-
unreachable by touch), quiet enough to stay out of the row's scan. */}
|
|
269
|
-
{note != null && note.value === "" && !noteEdit.editing ? (
|
|
270
|
-
<IconButton
|
|
271
|
-
ref={noteRestRef}
|
|
272
|
-
icon="sticky-note"
|
|
273
|
-
size="sm"
|
|
274
|
-
iconColor={colors.zinc[400]}
|
|
275
|
-
// The tooltip IS the accessible name (`IconButton` falls back to it).
|
|
276
|
-
tooltip={note.addLabel ?? labels.addNote}
|
|
277
|
-
onPress={noteEdit.begin}
|
|
278
|
-
/>
|
|
279
|
-
) : null}
|
|
280
|
-
{trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
|
|
281
|
-
{menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
|
|
282
|
-
</View>
|
|
283
|
-
{note != null && (noteEdit.editing || note.value !== "") ? (
|
|
284
|
-
<View style={[styles.note, { paddingLeft: indent }]}>
|
|
285
|
-
{noteEdit.editing ? (
|
|
286
|
-
<View>
|
|
287
|
-
<View style={styles.noteEditor}>
|
|
288
|
-
<TextInputField
|
|
289
|
-
value={noteEdit.draft}
|
|
290
|
-
onChangeText={noteEdit.setDraft}
|
|
291
|
-
onBlur={commitNote}
|
|
292
|
-
onKeyPress={onNoteKeyPress}
|
|
293
|
-
autoFocus
|
|
294
|
-
placeholder={notePlaceholder}
|
|
295
|
-
accessibilityLabel={notePlaceholder}
|
|
296
|
-
style={styles.noteInput}
|
|
297
|
-
/>
|
|
298
|
-
{/* The spinner floats inside the field's right edge — an async
|
|
299
|
-
save must never resize the line. */}
|
|
300
|
-
{noteEdit.saving ? (
|
|
301
|
-
<View style={styles.noteSaving} pointerEvents="none">
|
|
302
|
-
<ActivityIndicator size={14} color={colors.zinc[400]} />
|
|
303
|
-
</View>
|
|
304
|
-
) : null}
|
|
305
|
-
</View>
|
|
306
|
-
{noteEdit.error ? (
|
|
307
|
-
<Text size="xs" color="danger" style={styles.noteError}>
|
|
308
|
-
{noteEdit.error}
|
|
309
|
-
</Text>
|
|
310
|
-
) : null}
|
|
311
|
-
</View>
|
|
312
|
-
) : (
|
|
313
|
-
<FocusRingPressable
|
|
314
|
-
ref={noteRestRef}
|
|
315
|
-
onPress={noteEdit.begin}
|
|
316
|
-
accessibilityRole="button"
|
|
317
|
-
accessibilityLabel={note.editLabel ?? labels.editNote(note.value)}
|
|
318
|
-
userSelect="none"
|
|
319
|
-
// An editable VALUE, not a button: the arrow cursor stays and the
|
|
320
|
-
// hover is the dense-cell wash (the `InlineEditView` language).
|
|
321
|
-
style={(state) => [styles.noteLine, CONTROL_TRANSITION, state.hovered ? styles.noteLineHovered : null]}
|
|
322
|
-
>
|
|
323
|
-
<Text size="xs" color="muted">
|
|
324
|
-
{note.value}
|
|
325
|
-
</Text>
|
|
326
|
-
</FocusRingPressable>
|
|
327
|
-
)}
|
|
328
|
-
</View>
|
|
329
|
-
) : null}
|
|
330
|
-
{meta != null ? <View style={[styles.meta, { paddingLeft: indent }]}>{meta}</View> : null}
|
|
331
|
-
{subtaskItems != null && subtaskItems.length > 0 && expanded ? (
|
|
332
|
-
<View style={[styles.subtasks, { paddingLeft: indent }]} role="group" aria-labelledby={titleId}>
|
|
333
|
-
{subtaskItems.map((item) => (
|
|
334
|
-
<View key={item.key} style={styles.subtask}>
|
|
335
|
-
<CheckboxInput checked={item.checked} onChange={item.onToggle} accessibilityLabel={item.label} />
|
|
336
|
-
<Text
|
|
337
|
-
size="sm"
|
|
338
|
-
color={item.checked ? "muted" : "default"}
|
|
339
|
-
decoration={item.checked ? "lineThrough" : undefined}
|
|
340
|
-
style={styles.subtaskLabel}
|
|
341
|
-
>
|
|
342
|
-
{item.label}
|
|
343
|
-
</Text>
|
|
344
|
-
</View>
|
|
345
|
-
))}
|
|
346
|
-
</View>
|
|
347
|
-
) : null}
|
|
348
|
-
{expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
|
|
349
|
-
</View>
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const styles = StyleSheet.create({
|
|
354
|
-
list: { gap: 4 },
|
|
355
|
-
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
356
|
-
title: { flex: 1 },
|
|
357
|
-
note: { paddingBottom: 2 },
|
|
358
|
-
// Resting and editing occupy the SAME 28px band on the title's text inset,
|
|
359
|
-
// so opening the note never nudges the rows below it.
|
|
360
|
-
noteLine: { alignSelf: "flex-start", minHeight: 28, justifyContent: "center", borderRadius: CONTROL_RADIUS, paddingHorizontal: 8, paddingVertical: 4, cursor: "auto" },
|
|
361
|
-
noteLineHovered: { backgroundColor: colors.zinc[100] },
|
|
362
|
-
noteEditor: { position: "relative" },
|
|
363
|
-
noteInput: { borderWidth: 0, backgroundColor: "transparent", height: 28, paddingVertical: 2, paddingHorizontal: 8 },
|
|
364
|
-
noteSaving: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
|
|
365
|
-
noteError: { paddingHorizontal: 8 },
|
|
366
|
-
// The expander rides the row's 24px affordance band beside the note button —
|
|
367
|
-
// never the leading edge, which belongs to `control`.
|
|
368
|
-
expander: { flexDirection: "row", alignItems: "center", gap: 2, height: 24, paddingHorizontal: 4, borderRadius: CONTROL_RADIUS },
|
|
369
|
-
expanderHovered: { backgroundColor: colors.zinc[100] },
|
|
370
|
-
meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
|
|
371
|
-
// Child rows are DENSER than the parent (24 vs 32): the indent alone doesn't
|
|
372
|
-
// read as subordinate on a long list — the rhythm has to say it too.
|
|
373
|
-
subtasks: { paddingTop: 2, paddingBottom: 4, gap: 2 },
|
|
374
|
-
subtask: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 24 },
|
|
375
|
-
subtaskLabel: { flex: 1 },
|
|
376
|
-
expansion: { paddingTop: 2, paddingBottom: 6 },
|
|
377
|
-
});
|