@lotics/ui 16.1.0 → 17.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 +47 -0
- package/docs/catalog.md +51 -30
- package/docs/composition.md +3 -1
- package/docs/data_entry.md +41 -15
- package/docs/templates.md +13 -9
- package/examples/tpl_item_list.tsx +47 -44
- package/examples/tpl_record.tsx +87 -50
- package/examples/tpl_task_board.tsx +7 -1
- package/package.json +2 -2
- package/src/capture_row.tsx +7 -2
- package/src/check_circle.tsx +62 -18
- package/src/date_picker.tsx +21 -0
- package/src/detail_row.tsx +14 -4
- package/src/form_date_picker.tsx +3 -1
- package/src/form_field.tsx +4 -1
- package/src/icon.tsx +2 -0
- package/src/inline_edit.tsx +35 -25
- package/src/locale.tsx +2 -2
- package/src/task.tsx +285 -0
- package/src/checklist.tsx +0 -119
package/src/inline_edit.tsx
CHANGED
|
@@ -90,6 +90,34 @@ export function useInlineEdit<T>(opts: {
|
|
|
90
90
|
return { editing, draft, setDraft, saving, error, begin, cancel, commit };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Keyboard TAB-ORDER continuity for any inline editor: when edit mode closes
|
|
95
|
+
* while its input still holds focus (Enter, Escape, a ✓/✕), the unmount drops
|
|
96
|
+
* focus to `<body>` and the next Tab would restart from the top of the page —
|
|
97
|
+
* so `restore` is called to put focus back on the RESTING control.
|
|
98
|
+
*
|
|
99
|
+
* Gated on KEYBOARD modality: a POINTER close also drops focus to `<body>`, but
|
|
100
|
+
* restoring there both steals focus from a mouse user AND — because a bare
|
|
101
|
+
* `.focus()` scrolls the target into view — jumps the scroll position. A mouse
|
|
102
|
+
* user has no "next Tab" to preserve. A Tab-away blur-commit leaves focus on the
|
|
103
|
+
* next field (not `<body>`), so this never steals focus back either.
|
|
104
|
+
*/
|
|
105
|
+
export function useInlineEditFocusRestore(editing: boolean, restore: () => void) {
|
|
106
|
+
const wasEditing = useRef(editing);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (
|
|
109
|
+
wasEditing.current &&
|
|
110
|
+
!editing &&
|
|
111
|
+
typeof document !== "undefined" &&
|
|
112
|
+
document.activeElement === document.body &&
|
|
113
|
+
shouldRestoreFocusOnClose(getInteractionModality())
|
|
114
|
+
) {
|
|
115
|
+
restore();
|
|
116
|
+
}
|
|
117
|
+
wasEditing.current = editing;
|
|
118
|
+
}, [editing, restore]);
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
/** Which surface an inline editor lives on — the ONE axis that separates a FORM
|
|
94
122
|
* field from a data-grid CELL:
|
|
95
123
|
* - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
|
|
@@ -243,7 +271,6 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
243
271
|
|
|
244
272
|
const viewRef = useRef<View>(null);
|
|
245
273
|
const suppressedAt = useRef<number | null>(null);
|
|
246
|
-
const wasEditing = useRef(editing);
|
|
247
274
|
|
|
248
275
|
// Keyboard focus opens edit mode immediately; the input's own `autoFocus`
|
|
249
276
|
// then moves focus into it. Pointer focus never lands here (mousedown records
|
|
@@ -255,32 +282,15 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
255
282
|
onBegin();
|
|
256
283
|
}, [disabled, onBegin]);
|
|
257
284
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// (not <body>), so this never steals focus back.
|
|
264
|
-
//
|
|
265
|
-
// Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
|
|
266
|
-
// continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
|
|
267
|
-
// space) also drops focus to <body>, but restoring it there both steals focus
|
|
268
|
-
// from a mouse user AND — because a bare `.focus()` scrolls the target into
|
|
269
|
-
// view — jumps the scroll position (often to the top of the page). A mouse
|
|
270
|
-
// user has no "next Tab" to preserve, so skip the restore for pointer closes.
|
|
271
|
-
useEffect(() => {
|
|
272
|
-
if (
|
|
273
|
-
wasEditing.current &&
|
|
274
|
-
!editing &&
|
|
275
|
-
typeof document !== "undefined" &&
|
|
276
|
-
document.activeElement === document.body &&
|
|
277
|
-
shouldRestoreFocusOnClose(getInteractionModality())
|
|
278
|
-
) {
|
|
285
|
+
// Focus returns to the view button on a keyboard close, arming the suppression
|
|
286
|
+
// window so the programmatic focus doesn't re-open the editor it just closed.
|
|
287
|
+
useInlineEditFocusRestore(
|
|
288
|
+
editing,
|
|
289
|
+
useCallback(() => {
|
|
279
290
|
suppressedAt.current = Date.now();
|
|
280
291
|
viewRef.current?.focus();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
}, [editing]);
|
|
292
|
+
}, []),
|
|
293
|
+
);
|
|
284
294
|
|
|
285
295
|
if (!editing) {
|
|
286
296
|
return (
|
package/src/locale.tsx
CHANGED
|
@@ -162,7 +162,7 @@ export const en: LoticsLocale = {
|
|
|
162
162
|
descending: ", descending",
|
|
163
163
|
},
|
|
164
164
|
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
165
|
-
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
|
|
165
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
|
|
166
166
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
167
167
|
filterChip: { clear: "Clear" },
|
|
168
168
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -260,7 +260,7 @@ export const vi: LoticsLocale = {
|
|
|
260
260
|
descending: " (giảm dần)",
|
|
261
261
|
},
|
|
262
262
|
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
263
|
-
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
|
|
263
|
+
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
|
|
264
264
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
265
265
|
filterChip: { clear: "Xóa" },
|
|
266
266
|
floatingActionBar: { clear: "Bỏ chọn" },
|
package/src/task.tsx
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
* The inline controls rest inside 8px of horizontal padding, so a `variant="cell"` title's
|
|
75
|
+
* TEXT starts 8px inside its box. Everything that hangs beneath a row — the caption, the
|
|
76
|
+
* detail, a nested list — adds the same inset, so it lines up with the words rather than with
|
|
77
|
+
* the editor's invisible box. A plain-`Text` title should carry it too (`TASK_TEXT_INSET`),
|
|
78
|
+
* or its caption will sit 8px to its right.
|
|
79
|
+
*/
|
|
80
|
+
export const TASK_TEXT_INSET = 8;
|
|
81
|
+
|
|
82
|
+
export interface TaskListProps {
|
|
83
|
+
children: ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* Width of every row's leading control — `CheckCircle` 20 (the default), `CheckboxInput`
|
|
86
|
+
* 24. Detail blocks and nested lists indent by it so they sit on the title's text edge.
|
|
87
|
+
* Set on the ROOT list; a nested list inherits it.
|
|
88
|
+
*/
|
|
89
|
+
controlWidth?: number;
|
|
90
|
+
/**
|
|
91
|
+
* `comfortable` (the default) gives every row a 44px minimum so a finger can hit it;
|
|
92
|
+
* `dense` drops to 32px for a pointer-driven register. Density belongs to the SURFACE, so
|
|
93
|
+
* it is set once here and inherited by nested lists.
|
|
94
|
+
*/
|
|
95
|
+
density?: TaskDensity;
|
|
96
|
+
/**
|
|
97
|
+
* Fixed width for every row's `TaskFields`, so the due/assignee cells line up as a COLUMN
|
|
98
|
+
* down the list instead of drifting with each title's length. Set it once here — a row
|
|
99
|
+
* with fewer cells still reserves the width, which is what keeps the column straight.
|
|
100
|
+
* Omit it and each cluster hugs its own content (fine for a single row, jittery in a list).
|
|
101
|
+
*/
|
|
102
|
+
fieldsWidth?: number;
|
|
103
|
+
accessibilityLabel?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The list — owns geometry (control column, indent, density) for every descendant. Nest one
|
|
108
|
+
* inside a `TaskItem` and its rows become that task's subtasks, indented one column.
|
|
109
|
+
*/
|
|
110
|
+
export function TaskList(props: TaskListProps) {
|
|
111
|
+
const parent = useContext(TaskListContext);
|
|
112
|
+
const isNested = parent.depth > 0;
|
|
113
|
+
const controlWidth = props.controlWidth ?? parent.controlWidth;
|
|
114
|
+
const density = props.density ?? parent.density;
|
|
115
|
+
const fieldsWidth = props.fieldsWidth ?? parent.fieldsWidth;
|
|
116
|
+
return (
|
|
117
|
+
<TaskListContext.Provider value={{ controlWidth, depth: parent.depth + 1, density, fieldsWidth }}>
|
|
118
|
+
<View
|
|
119
|
+
style={[
|
|
120
|
+
styles.list,
|
|
121
|
+
// A nested list is a child-step list: indent it to the parent's title text edge.
|
|
122
|
+
isNested && { flexBasis: "100%", marginTop: 2, marginBottom: 2 },
|
|
123
|
+
]}
|
|
124
|
+
role="list"
|
|
125
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
126
|
+
>
|
|
127
|
+
{props.children}
|
|
128
|
+
</View>
|
|
129
|
+
</TaskListContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One task. Its children lay out as a WRAPPING row — `TaskStatus`, `TaskTitle`, `TaskFields`,
|
|
135
|
+
* `TaskActions` — while `TaskDetail` and a nested `TaskList` are full-width and fall beneath
|
|
136
|
+
* it. Order in JSX is order on screen; nothing inspects child types.
|
|
137
|
+
*/
|
|
138
|
+
export function TaskItem(props: { children: ReactNode }) {
|
|
139
|
+
const { controlWidth } = useContext(TaskListContext);
|
|
140
|
+
// The control is taken OUT of the wrapping flow and pinned to the left gutter, so the row's
|
|
141
|
+
// content — title, fields, caption, detail, a nested list — all share ONE column. Anything
|
|
142
|
+
// that wraps therefore lands under the title instead of under the checkbox, which is what
|
|
143
|
+
// made a wrapped field cluster read as a row of its own, belonging to nothing.
|
|
144
|
+
//
|
|
145
|
+
// The minimum tap target is the ROW's, not each slot's: boxing every slot at 44px centred
|
|
146
|
+
// the title in it and pushed the caption ~13px below the words it describes.
|
|
147
|
+
return (
|
|
148
|
+
<View
|
|
149
|
+
style={[styles.item, { minHeight: useRowMinHeight(), paddingLeft: controlWidth + ROW_GAP }]}
|
|
150
|
+
role="listitem"
|
|
151
|
+
>
|
|
152
|
+
{props.children}
|
|
153
|
+
</View>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function useRowMinHeight(): number {
|
|
158
|
+
const { density } = useContext(TaskListContext);
|
|
159
|
+
return density === "dense" ? DENSE_ROW : TOUCH_TARGET;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The leading control — a `CheckCircle`, a `CheckboxInput`, a status dot. Pinned to the
|
|
163
|
+
* list's control column so every title starts on the same edge. */
|
|
164
|
+
export function TaskStatus(props: { children: ReactNode }) {
|
|
165
|
+
const { controlWidth } = useContext(TaskListContext);
|
|
166
|
+
// Pinned to the gutter and sized to the FIRST line, so it stays beside the title however
|
|
167
|
+
// tall the row grows underneath it.
|
|
168
|
+
return (
|
|
169
|
+
<View style={[styles.status, { width: controlWidth, height: useRowMinHeight() }]}>
|
|
170
|
+
{props.children}
|
|
171
|
+
</View>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The task's identity — plain `Text` or an inline editor. Takes the row's spare width and
|
|
176
|
+
* claims a readable minimum, which is what makes `TaskFields` wrap rather than crush it. */
|
|
177
|
+
export function TaskTitle(props: { children: ReactNode }) {
|
|
178
|
+
return (
|
|
179
|
+
<View style={styles.title}>{props.children}</View>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The descriptor cluster — due date, assignee, chips, counts. Sits inline after the title
|
|
185
|
+
* while the row has room and takes its own line when it does not.
|
|
186
|
+
*
|
|
187
|
+
* Replaces the old `trailing` (wide) / `meta` (narrow) pair, which made the author pick one
|
|
188
|
+
* at authoring time and be wrong on the other surface.
|
|
189
|
+
*/
|
|
190
|
+
export function TaskFields(props: { children: ReactNode }) {
|
|
191
|
+
const { fieldsWidth } = useContext(TaskListContext);
|
|
192
|
+
return (
|
|
193
|
+
<View
|
|
194
|
+
style={[
|
|
195
|
+
styles.fields,
|
|
196
|
+
// A reserved width is what makes a COLUMN: every row gives its cells the same box,
|
|
197
|
+
// so a row missing a due date does not pull its assignee leftward out of line.
|
|
198
|
+
fieldsWidth !== undefined && { width: fieldsWidth },
|
|
199
|
+
]}
|
|
200
|
+
>
|
|
201
|
+
{props.children}
|
|
202
|
+
</View>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A second line under the title — what this task's STATE is, in words: "Needs: Carrier,
|
|
208
|
+
* Vehicle plate", "Waiting on the yard", "3 of 5 papers received".
|
|
209
|
+
*
|
|
210
|
+
* Deliberately NOT part of `TaskFields`. A field is a VALUE the reader edits and scans down a
|
|
211
|
+
* column; a caption is a SENTENCE about this row. Merging them (as the first cut of this
|
|
212
|
+
* family did) pushes prose into the value column, where it aligns with nothing and squeezes
|
|
213
|
+
* the cells. It always takes its own line, on the title's text edge — never inline, never
|
|
214
|
+
* behind a disclosure, because a state the reader must open to see is a state they will miss.
|
|
215
|
+
*/
|
|
216
|
+
export function TaskCaption(props: { children: ReactNode }) {
|
|
217
|
+
return (
|
|
218
|
+
<View style={[styles.caption, { paddingLeft: TASK_TEXT_INSET }]}>{props.children}</View>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The row's actions — an `ActionMenu`, an `IconButton`. A destructive item belongs behind
|
|
223
|
+
* the menu, never as a bare ✕ on the row, so a stray tap cannot destroy a task. */
|
|
224
|
+
export function TaskActions(props: { children: ReactNode }) {
|
|
225
|
+
return (
|
|
226
|
+
<View style={styles.actions}>{props.children}</View>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A block beneath the row, indented to the title's text edge — an inline editor, a
|
|
231
|
+
* drill-down, the task's own fields. Render it only while open. */
|
|
232
|
+
export function TaskDetail(props: { children: ReactNode }) {
|
|
233
|
+
return (
|
|
234
|
+
<View style={[styles.detail, { marginLeft: TASK_TEXT_INSET }]}>{props.children}</View>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const styles = StyleSheet.create({
|
|
239
|
+
// No gap: every row already carries the density's min-height, so the rhythm is the ROW,
|
|
240
|
+
// not the space between rows. A gap on top of it made the list read as loose pairs.
|
|
241
|
+
list: { gap: 0 },
|
|
242
|
+
item: {
|
|
243
|
+
position: "relative",
|
|
244
|
+
flexDirection: "row",
|
|
245
|
+
flexWrap: "wrap",
|
|
246
|
+
alignItems: "center",
|
|
247
|
+
// `align-items` centres within a LINE; with `flex-wrap: wrap` it is `align-content` that
|
|
248
|
+
// places the lines in the box. Without it a short row (a plain-text subtask, 20px) sat at
|
|
249
|
+
// the top of its 44px target while the gutter control centred — the control read as
|
|
250
|
+
// belonging to the row below.
|
|
251
|
+
alignContent: "center",
|
|
252
|
+
columnGap: ROW_GAP,
|
|
253
|
+
// Lines inside ONE task are the same thought (title → caption → detail), so they sit
|
|
254
|
+
// tight; `gap` would have applied the 12px column rhythm vertically too.
|
|
255
|
+
rowGap: 2,
|
|
256
|
+
},
|
|
257
|
+
status: { position: "absolute", left: 0, top: 0, justifyContent: "center", alignItems: "flex-start" },
|
|
258
|
+
// flexBasis at the minimum (not 0) is what drives the wrap: once title + fields cannot
|
|
259
|
+
// both fit, the fields — which never shrink — are pushed to the next line.
|
|
260
|
+
title: {
|
|
261
|
+
flexGrow: 1,
|
|
262
|
+
flexShrink: 1,
|
|
263
|
+
flexBasis: TITLE_MIN_WIDTH,
|
|
264
|
+
minWidth: TITLE_MIN_WIDTH,
|
|
265
|
+
justifyContent: "center",
|
|
266
|
+
},
|
|
267
|
+
fields: {
|
|
268
|
+
flexDirection: "row",
|
|
269
|
+
alignItems: "center",
|
|
270
|
+
justifyContent: "flex-end",
|
|
271
|
+
// NOWRAP: the cluster is a ROW of cells, and it wraps as a UNIT under the title. Letting
|
|
272
|
+
// it wrap internally split the cells across two lines with the ⋯ stranded between them.
|
|
273
|
+
// Too little room means the cells compress, never that they stack.
|
|
274
|
+
flexWrap: "nowrap",
|
|
275
|
+
gap: 8,
|
|
276
|
+
// Shrinkable, so a narrow container compresses the cells instead of shoving the ⋯ onto a
|
|
277
|
+
// line of its own. `fieldsWidth` still pins the column when a list wants one.
|
|
278
|
+
flexShrink: 1,
|
|
279
|
+
minWidth: 0,
|
|
280
|
+
},
|
|
281
|
+
// `flexBasis: "100%"` is the whole trick: the caption always breaks to its own line.
|
|
282
|
+
caption: { flexBasis: "100%" },
|
|
283
|
+
actions: { flexGrow: 0, flexShrink: 0, justifyContent: "center" },
|
|
284
|
+
detail: { flexBasis: "100%", borderLeftWidth: 1, borderLeftColor: colors.border, paddingLeft: ROW_GAP },
|
|
285
|
+
});
|
package/src/checklist.tsx
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
3
|
-
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
4
|
-
|
|
5
|
-
// The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
|
|
6
|
-
// (row height, control/title alignment, the meta/expansion indent, one
|
|
7
|
-
// trailing column width) while the CONTENT stays composed: tasks (a
|
|
8
|
-
// `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
|
|
9
|
-
// rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
|
|
10
|
-
// readiness `meta` line and a fill editor in `expansion`) are the same
|
|
11
|
-
// anatomy. `menu` carries the row's ⋯ options (Delete lives BEHIND the menu
|
|
12
|
-
// — indirection that prevents
|
|
13
|
-
// accidental destructive taps). Suggested tasks are NOT rows: offer the record
|
|
14
|
-
// type's commons as `SuggestionChip`s under the list (tap = materialize,
|
|
15
|
-
// ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
|
|
16
|
-
// list as its add affordance. There is deliberately NO monolithic Task
|
|
17
|
-
// component — richer task-management rows (expand affordances, tag/clip meta,
|
|
18
|
-
// board cards) compose their own anatomy directly.
|
|
19
|
-
//
|
|
20
|
-
// <Checklist trailingWidth={140}>
|
|
21
|
-
// <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
|
|
22
|
-
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
23
|
-
// accessibilityLabel: "Task options: …" }}>
|
|
24
|
-
// <InlineTextInput variant="cell" struck={done} … />
|
|
25
|
-
// </ChecklistRow>
|
|
26
|
-
// <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
|
|
27
|
-
// <CaptureRow … />
|
|
28
|
-
// </Checklist>
|
|
29
|
-
|
|
30
|
-
interface ChecklistContextValue {
|
|
31
|
-
trailingWidth?: number;
|
|
32
|
-
controlWidth: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const ChecklistContext = createContext<ChecklistContextValue>({ controlWidth: 20 });
|
|
36
|
-
|
|
37
|
-
export interface ChecklistProps {
|
|
38
|
-
/** Fixed width of every row's trailing cell (an assignee select) — set it
|
|
39
|
-
* once so the column lines up; omit when rows carry no trailing. */
|
|
40
|
-
trailingWidth?: number;
|
|
41
|
-
/** Width of every row's leading control — `CheckCircle` 20 (the default),
|
|
42
|
-
* `CheckboxInput` 24. The `meta`/`expansion` indent derives from it, so
|
|
43
|
-
* the second line always sits on the title's text edge. */
|
|
44
|
-
controlWidth?: number;
|
|
45
|
-
children: ReactNode;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function Checklist(props: ChecklistProps) {
|
|
49
|
-
const { trailingWidth, controlWidth = 20, children } = props;
|
|
50
|
-
return (
|
|
51
|
-
<ChecklistContext.Provider value={{ trailingWidth, controlWidth }}>
|
|
52
|
-
<View style={styles.list}>{children}</View>
|
|
53
|
-
</ChecklistContext.Provider>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface ChecklistRowBaseProps {
|
|
58
|
-
/** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
|
|
59
|
-
control: ReactNode;
|
|
60
|
-
/** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
|
|
61
|
-
children: ReactNode;
|
|
62
|
-
/** The row's ⋯ options menu (destructive items last, `danger: true`) —
|
|
63
|
-
* Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
|
|
64
|
-
* destroy a task. Name the task in the label ("Task options: Book the
|
|
65
|
-
* carrier"). The ⋯ column aligns only when every row in the list carries
|
|
66
|
-
* a menu — keep its presence uniform per list. */
|
|
67
|
-
menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
|
|
68
|
-
/** A BLOCK slot under the row (below `meta`), indented to the title's text
|
|
69
|
-
* edge — a transient inline fill editor, a drill-down. Render it only
|
|
70
|
-
* while open; the list's geometry (the indent) is owned here. */
|
|
71
|
-
expansion?: ReactNode;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
|
|
75
|
-
export type ChecklistRowProps = ChecklistRowBaseProps &
|
|
76
|
-
(
|
|
77
|
-
| {
|
|
78
|
-
/** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
|
|
79
|
-
trailing?: ReactNode;
|
|
80
|
-
meta?: never;
|
|
81
|
-
}
|
|
82
|
-
| {
|
|
83
|
-
/** SECOND line under the title (indented past the ring) for a NARROW
|
|
84
|
-
* surface — a drawer or popover checklist: put the assignee/due
|
|
85
|
-
* editors here so the title keeps the full width and stays readable. */
|
|
86
|
-
meta?: ReactNode;
|
|
87
|
-
trailing?: never;
|
|
88
|
-
}
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
/** One checklist line: control · title (flex) · the aligned trailing cell · ⋯,
|
|
92
|
-
* with an optional indented `meta` line below for narrow surfaces and an
|
|
93
|
-
* optional `expansion` block under that (both sit on the title's text edge). */
|
|
94
|
-
export function ChecklistRow(props: ChecklistRowProps) {
|
|
95
|
-
const { control, children, trailing, menu, meta, expansion } = props;
|
|
96
|
-
const { trailingWidth, controlWidth } = useContext(ChecklistContext);
|
|
97
|
-
// Indent past the control + the row gap so meta/expansion align with the title.
|
|
98
|
-
const indent = controlWidth + 12;
|
|
99
|
-
return (
|
|
100
|
-
<View>
|
|
101
|
-
<View style={styles.row}>
|
|
102
|
-
{control}
|
|
103
|
-
<View style={styles.title}>{children}</View>
|
|
104
|
-
{trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
|
|
105
|
-
{menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
|
|
106
|
-
</View>
|
|
107
|
-
{meta != null ? <View style={[styles.meta, { paddingLeft: indent }]}>{meta}</View> : null}
|
|
108
|
-
{expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
|
|
109
|
-
</View>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const styles = StyleSheet.create({
|
|
114
|
-
list: { gap: 4 },
|
|
115
|
-
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
116
|
-
title: { flex: 1 },
|
|
117
|
-
meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
|
|
118
|
-
expansion: { paddingTop: 2, paddingBottom: 6 },
|
|
119
|
-
});
|