@lotics/ui 16.0.0 → 16.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/docs/catalog.md +47 -8
- package/docs/composition.md +3 -1
- package/docs/data_entry.md +41 -2
- package/docs/templates.md +10 -2
- package/examples/tpl_item_list.tsx +3 -3
- package/examples/tpl_record.tsx +71 -17
- package/package.json +4 -2
- package/src/button.tsx +10 -11
- package/src/checklist.tsx +275 -17
- 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/icon_button.tsx +8 -9
- package/src/inline_edit.tsx +41 -26
- package/src/locale.tsx +26 -2
- package/src/pending_commits.test.ts +68 -0
- package/src/pending_commits.ts +45 -0
- package/src/use_gated_press.ts +41 -0
package/src/checklist.tsx
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
1
|
+
import { createContext, useCallback, useContext, useId, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
|
|
3
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";
|
|
4
15
|
|
|
5
16
|
// The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
|
|
6
|
-
// (row height, control/title alignment, the meta/expansion
|
|
7
|
-
// trailing column width) while the CONTENT stays composed: tasks (a
|
|
17
|
+
// (row height, control/title alignment, the note/meta/subtask/expansion
|
|
18
|
+
// indent, one trailing column width) while the CONTENT stays composed: tasks (a
|
|
8
19
|
// `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
|
|
9
20
|
// rows (a `CheckboxInput` in `control` — set `controlWidth={24}` — with a
|
|
10
21
|
// readiness `meta` line and a fill editor in `expansion`) are the same
|
|
11
|
-
// anatomy. `
|
|
12
|
-
//
|
|
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
|
|
13
26
|
// accidental destructive taps). Suggested tasks are NOT rows: offer the record
|
|
14
27
|
// type's commons as `SuggestionChip`s under the list (tap = materialize,
|
|
15
28
|
// ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
|
|
@@ -19,6 +32,9 @@ import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
|
19
32
|
//
|
|
20
33
|
// <Checklist trailingWidth={140}>
|
|
21
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) })) }}
|
|
22
38
|
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
23
39
|
// accessibilityLabel: "Task options: …" }}>
|
|
24
40
|
// <InlineTextInput variant="cell" struck={done} … />
|
|
@@ -39,8 +55,9 @@ export interface ChecklistProps {
|
|
|
39
55
|
* once so the column lines up; omit when rows carry no trailing. */
|
|
40
56
|
trailingWidth?: number;
|
|
41
57
|
/** Width of every row's leading control — `CheckCircle` 20 (the default),
|
|
42
|
-
* `CheckboxInput` 24. The `meta`/`expansion` indent
|
|
43
|
-
*
|
|
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. */
|
|
44
61
|
controlWidth?: number;
|
|
45
62
|
children: ReactNode;
|
|
46
63
|
}
|
|
@@ -54,20 +71,100 @@ export function Checklist(props: ChecklistProps) {
|
|
|
54
71
|
);
|
|
55
72
|
}
|
|
56
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
|
+
|
|
57
141
|
interface ChecklistRowBaseProps {
|
|
58
142
|
/** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
|
|
59
143
|
control: ReactNode;
|
|
60
144
|
/** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
|
|
61
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;
|
|
62
152
|
/** The row's ⋯ options menu (destructive items last, `danger: true`) —
|
|
63
153
|
* Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
|
|
64
154
|
* destroy a task. Name the task in the label ("Task options: Book the
|
|
65
155
|
* carrier"). The ⋯ column aligns only when every row in the list carries
|
|
66
156
|
* a menu — keep its presence uniform per list. */
|
|
67
157
|
menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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. */
|
|
71
168
|
expansion?: ReactNode;
|
|
72
169
|
}
|
|
73
170
|
|
|
@@ -88,23 +185,166 @@ export type ChecklistRowProps = ChecklistRowBaseProps &
|
|
|
88
185
|
}
|
|
89
186
|
);
|
|
90
187
|
|
|
91
|
-
/** One checklist line: control · title (flex) · the
|
|
92
|
-
*
|
|
93
|
-
* optional `
|
|
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). */
|
|
94
193
|
export function ChecklistRow(props: ChecklistRowProps) {
|
|
95
|
-
const { control, children, trailing, menu, meta, expansion } = props;
|
|
194
|
+
const { control, children, trailing, menu, meta, note, subtasks, expansion } = props;
|
|
96
195
|
const { trailingWidth, controlWidth } = useContext(ChecklistContext);
|
|
97
|
-
|
|
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.
|
|
98
237
|
const indent = controlWidth + 12;
|
|
99
238
|
return (
|
|
100
239
|
<View>
|
|
101
240
|
<View style={styles.row}>
|
|
102
241
|
{control}
|
|
103
|
-
<View style={styles.title}
|
|
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}
|
|
104
280
|
{trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
|
|
105
281
|
{menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
|
|
106
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}
|
|
107
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}
|
|
108
348
|
{expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
|
|
109
349
|
</View>
|
|
110
350
|
);
|
|
@@ -114,6 +354,24 @@ const styles = StyleSheet.create({
|
|
|
114
354
|
list: { gap: 4 },
|
|
115
355
|
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
116
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] },
|
|
117
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 },
|
|
118
376
|
expansion: { paddingTop: 2, paddingBottom: 6 },
|
|
119
377
|
});
|
package/src/date_picker.tsx
CHANGED
|
@@ -48,6 +48,10 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
48
48
|
startTime: string;
|
|
49
49
|
/** Accessible name for the end-time field (datetime range). */
|
|
50
50
|
endTime: string;
|
|
51
|
+
/** Accessible name for the start-date segment group (range formats). */
|
|
52
|
+
startDate: string;
|
|
53
|
+
/** Accessible name for the end-date segment group (range formats). */
|
|
54
|
+
endDate: string;
|
|
51
55
|
/** Footer action: commit and close the popover. */
|
|
52
56
|
done: string;
|
|
53
57
|
/** Inline error when a typed date is left incomplete/invalid (never committed). */
|
|
@@ -79,6 +83,11 @@ export interface DatePickerProps {
|
|
|
79
83
|
locale?: string;
|
|
80
84
|
/** Shown in the field when nothing is selected yet. */
|
|
81
85
|
placeholder?: string;
|
|
86
|
+
/** Accessible name for the field's segment group (both groups on a range,
|
|
87
|
+
* suffixed with the start/end names). `FormDatePicker` defaults it to its own
|
|
88
|
+
* visible label; a bare `DatePicker` must be given one — a visible label
|
|
89
|
+
* beside the field is not programmatically associated. */
|
|
90
|
+
accessibilityLabel?: string;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
// =============================================================================
|
|
@@ -328,6 +337,7 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
328
337
|
labels,
|
|
329
338
|
locale,
|
|
330
339
|
placeholder,
|
|
340
|
+
accessibilityLabel,
|
|
331
341
|
} = props;
|
|
332
342
|
|
|
333
343
|
const isRange = isRangeFormat(format);
|
|
@@ -385,10 +395,21 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
385
395
|
<Icon name="calendar" size={20} color={disabled ? colors.zinc["300"] : colors.zinc["400"]} />
|
|
386
396
|
);
|
|
387
397
|
|
|
398
|
+
// Each segment group's accessible name. A range names both halves even without
|
|
399
|
+
// a field label — an unnamed pair of identical-looking groups is unnavigable.
|
|
400
|
+
const partLabels = isRange
|
|
401
|
+
? ([mergedLabels.startDate, mergedLabels.endDate].map((part) =>
|
|
402
|
+
accessibilityLabel ? `${accessibilityLabel} — ${part}` : part,
|
|
403
|
+
) as string[])
|
|
404
|
+
: accessibilityLabel
|
|
405
|
+
? [accessibilityLabel]
|
|
406
|
+
: undefined;
|
|
407
|
+
|
|
388
408
|
const trigger = (
|
|
389
409
|
<DateField
|
|
390
410
|
triggerRef={triggerRef}
|
|
391
411
|
testID={testID}
|
|
412
|
+
partLabels={partLabels}
|
|
392
413
|
parts={isRange ? halves : [value ?? ""]}
|
|
393
414
|
onPartChange={isRange ? setHalf : (_, next) => onValueChange(next)}
|
|
394
415
|
hasTime={hasTime}
|
package/src/detail_row.tsx
CHANGED
|
@@ -155,7 +155,11 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
155
155
|
const flatTuck = flat && annotated ? -Math.max(0, Math.round((minHeight - getInputLineHeight(false)) / 2)) : 0;
|
|
156
156
|
const labelCell = (cellStyle: StyleProp<ViewStyle>) => (
|
|
157
157
|
<View style={[styles.labelCell, { minHeight }, cellStyle]}>
|
|
158
|
-
|
|
158
|
+
{/* The label WRAPS inside its column — it is NEVER clipped. A field name
|
|
159
|
+
the reader can't finish is worse than a taller row, and a fixed
|
|
160
|
+
`labelWidth` guarantees the clip on every long name ("Registered
|
|
161
|
+
business address"), not just the odd one. */}
|
|
162
|
+
<Text size={labelSize} color="muted">
|
|
159
163
|
{label}
|
|
160
164
|
</Text>
|
|
161
165
|
</View>
|
|
@@ -191,9 +195,9 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
191
195
|
// inline editor self-persists — only the look converges.
|
|
192
196
|
return (
|
|
193
197
|
<View style={styles.stackedRow}>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
</Text>
|
|
198
|
+
{/* Wraps, like the horizontal label — a stacked row has the FULL width
|
|
199
|
+
to spend, so clipping here would be gratuitous. */}
|
|
200
|
+
<Text weight="medium">{label}</Text>
|
|
197
201
|
{/* stacked wears the FORM grammar exactly: label · description ·
|
|
198
202
|
control · warning · error (the `FormField` order) */}
|
|
199
203
|
{description != null ? <Text color="muted">{description}</Text> : null}
|
|
@@ -259,6 +263,12 @@ const styles = StyleSheet.create({
|
|
|
259
263
|
gap: 12,
|
|
260
264
|
},
|
|
261
265
|
flexLabel: { flex: 1 },
|
|
266
|
+
// `center` is what makes a WRAPPED label align sanely, and it does both jobs
|
|
267
|
+
// with no measurement: a one-line label is shorter than the control band, so
|
|
268
|
+
// it centers on the control line (the alignment law); a wrapped label is as
|
|
269
|
+
// tall as the band or taller, so it fills it and its FIRST line tops out
|
|
270
|
+
// level with the value's first control line. Do not "fix" this to
|
|
271
|
+
// `flex-start` — that drops every single-line label to the band's top edge.
|
|
262
272
|
labelCell: { justifyContent: "center" },
|
|
263
273
|
// The first line of the value cell — the control centers in it exactly like
|
|
264
274
|
// the label and trailing cells do.
|
package/src/form_date_picker.tsx
CHANGED
|
@@ -19,7 +19,9 @@ export function FormDatePicker(props: FormDatePickerProps) {
|
|
|
19
19
|
optional={optional}
|
|
20
20
|
optionalLabel={optionalLabel}
|
|
21
21
|
>
|
|
22
|
-
|
|
22
|
+
{/* The visible FormField label is not programmatically associated with the
|
|
23
|
+
segment inputs, so it doubles as the accessible name unless overridden. */}
|
|
24
|
+
<DatePicker accessibilityLabel={label} {...datePickerProps} />
|
|
23
25
|
</FormField>
|
|
24
26
|
);
|
|
25
27
|
}
|
package/src/form_field.tsx
CHANGED
|
@@ -75,8 +75,11 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
|
|
|
75
75
|
justifyContent: "space-between",
|
|
76
76
|
}}
|
|
77
77
|
>
|
|
78
|
+
{/* The label WRAPS (same law as DetailRow) — a clamp plus a fixed row
|
|
79
|
+
width guaranteed the clip. The optional marker keeps its single
|
|
80
|
+
line and never shrinks; the label yields. */}
|
|
78
81
|
{!!label && (
|
|
79
|
-
<Text nativeID={labelId}
|
|
82
|
+
<Text nativeID={labelId} weight="medium" style={{ flexShrink: 1 }}>
|
|
80
83
|
{label}
|
|
81
84
|
</Text>
|
|
82
85
|
)}
|
package/src/icon.tsx
CHANGED
|
@@ -176,6 +176,7 @@ import Square from "lucide-react-native/dist/esm/icons/square";
|
|
|
176
176
|
import SquareCheck from "lucide-react-native/dist/esm/icons/square-check";
|
|
177
177
|
import SquarePen from "lucide-react-native/dist/esm/icons/square-pen";
|
|
178
178
|
import SquareSigma from "lucide-react-native/dist/esm/icons/square-sigma";
|
|
179
|
+
import StickyNote from "lucide-react-native/dist/esm/icons/sticky-note";
|
|
179
180
|
import Table from "lucide-react-native/dist/esm/icons/table";
|
|
180
181
|
import Table2 from "lucide-react-native/dist/esm/icons/table-2";
|
|
181
182
|
import Tag from "lucide-react-native/dist/esm/icons/tag";
|
|
@@ -372,6 +373,7 @@ const iconComponents = {
|
|
|
372
373
|
"square-check": SquareCheck,
|
|
373
374
|
"square-pen": SquarePen,
|
|
374
375
|
"square-sigma": SquareSigma,
|
|
376
|
+
"sticky-note": StickyNote,
|
|
375
377
|
table: Table,
|
|
376
378
|
"table-2": Table2,
|
|
377
379
|
tag: Tag,
|
package/src/icon_button.tsx
CHANGED
|
@@ -4,8 +4,9 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
5
|
import { type ButtonColor } from "./button";
|
|
6
6
|
import { getButtonIconColor } from "./button_colors";
|
|
7
|
-
import { Ref
|
|
7
|
+
import { Ref } from "react";
|
|
8
8
|
import { TooltipSide } from "./tooltip";
|
|
9
|
+
import { useGatedPress } from "./use_gated_press";
|
|
9
10
|
|
|
10
11
|
interface IconButtonBase {
|
|
11
12
|
ref?: Ref<View>;
|
|
@@ -61,14 +62,12 @@ export function IconButton(props: IconButtonProps) {
|
|
|
61
62
|
style,
|
|
62
63
|
} = props;
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
[onPress],
|
|
69
|
-
);
|
|
65
|
+
// The press waits out an inline commit the same gesture started (pending_commits.ts)
|
|
66
|
+
// and reports busy while it does — `busy` drives the spinner + the double-press block.
|
|
67
|
+
const { handlePress, waiting } = useGatedPress<GestureResponderEvent>(onPress);
|
|
68
|
+
const busy = loading || waiting;
|
|
70
69
|
|
|
71
|
-
const disabledOrLoading = disabled ||
|
|
70
|
+
const disabledOrLoading = disabled || busy;
|
|
72
71
|
const tint =
|
|
73
72
|
iconColor ??
|
|
74
73
|
(color === "none" ? colors.zinc[700] : color === "white" ? colors.white : getButtonIconColor(color));
|
|
@@ -88,7 +87,7 @@ export function IconButton(props: IconButtonProps) {
|
|
|
88
87
|
// without shifting surrounding layouts.
|
|
89
88
|
hitSlop={size === "lg" ? 0 : size === "sm" ? 8 : 6}
|
|
90
89
|
>
|
|
91
|
-
{
|
|
90
|
+
{busy ? (
|
|
92
91
|
<ActivityIndicator size="small" color={tint} />
|
|
93
92
|
) : (
|
|
94
93
|
<Icon size={size === "sm" ? 14 : size === "lg" ? 20 : 18} name={icon} color={tint} />
|
package/src/inline_edit.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./
|
|
|
10
10
|
import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
|
|
11
11
|
import { getInteractionModality } from "./interaction_modality";
|
|
12
12
|
import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
13
|
+
import { trackCommit } from "./pending_commits";
|
|
13
14
|
|
|
14
15
|
/** The kit's standard control height (TextInputField, NumberInput, Picker, …).
|
|
15
16
|
* The view box matches it — same height, padding, and a 1px transparent border
|
|
@@ -69,7 +70,11 @@ export function useInlineEdit<T>(opts: {
|
|
|
69
70
|
setSaving(true);
|
|
70
71
|
setError(null);
|
|
71
72
|
try {
|
|
72
|
-
|
|
73
|
+
// Registered so an action pressed in the SAME gesture (the press that caused
|
|
74
|
+
// this blur) waits for the write instead of reading the pre-edit record —
|
|
75
|
+
// see pending_commits.ts. The IIFE starts `onSave` synchronously, so the
|
|
76
|
+
// write still leaves on mousedown, and turns a sync throw into a rejection.
|
|
77
|
+
await trackCommit((async () => onSave(candidate))());
|
|
73
78
|
setEditing(false);
|
|
74
79
|
} catch (e) {
|
|
75
80
|
// Stay in edit mode so the entry isn't lost — show the error, re-arm.
|
|
@@ -85,6 +90,34 @@ export function useInlineEdit<T>(opts: {
|
|
|
85
90
|
return { editing, draft, setDraft, saving, error, begin, cancel, commit };
|
|
86
91
|
}
|
|
87
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
|
+
|
|
88
121
|
/** Which surface an inline editor lives on — the ONE axis that separates a FORM
|
|
89
122
|
* field from a data-grid CELL:
|
|
90
123
|
* - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
|
|
@@ -238,7 +271,6 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
238
271
|
|
|
239
272
|
const viewRef = useRef<View>(null);
|
|
240
273
|
const suppressedAt = useRef<number | null>(null);
|
|
241
|
-
const wasEditing = useRef(editing);
|
|
242
274
|
|
|
243
275
|
// Keyboard focus opens edit mode immediately; the input's own `autoFocus`
|
|
244
276
|
// then moves focus into it. Pointer focus never lands here (mousedown records
|
|
@@ -250,32 +282,15 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
250
282
|
onBegin();
|
|
251
283
|
}, [disabled, onBegin]);
|
|
252
284
|
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// (not <body>), so this never steals focus back.
|
|
259
|
-
//
|
|
260
|
-
// Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
|
|
261
|
-
// continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
|
|
262
|
-
// space) also drops focus to <body>, but restoring it there both steals focus
|
|
263
|
-
// from a mouse user AND — because a bare `.focus()` scrolls the target into
|
|
264
|
-
// view — jumps the scroll position (often to the top of the page). A mouse
|
|
265
|
-
// user has no "next Tab" to preserve, so skip the restore for pointer closes.
|
|
266
|
-
useEffect(() => {
|
|
267
|
-
if (
|
|
268
|
-
wasEditing.current &&
|
|
269
|
-
!editing &&
|
|
270
|
-
typeof document !== "undefined" &&
|
|
271
|
-
document.activeElement === document.body &&
|
|
272
|
-
shouldRestoreFocusOnClose(getInteractionModality())
|
|
273
|
-
) {
|
|
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(() => {
|
|
274
290
|
suppressedAt.current = Date.now();
|
|
275
291
|
viewRef.current?.focus();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
}, [editing]);
|
|
292
|
+
}, []),
|
|
293
|
+
);
|
|
279
294
|
|
|
280
295
|
if (!editing) {
|
|
281
296
|
return (
|