@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/examples/tpl_record.tsx
CHANGED
|
@@ -32,7 +32,7 @@ import { CheckCircle } from "@lotics/ui/check_circle";
|
|
|
32
32
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
33
33
|
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
34
34
|
import { CaptureRow } from "@lotics/ui/capture_row";
|
|
35
|
-
import {
|
|
35
|
+
import { TaskActions, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
36
36
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
37
37
|
import { SuggestionChip } from "@lotics/ui/suggestion_chip";
|
|
38
38
|
import { OptionList } from "@lotics/ui/option_list";
|
|
@@ -165,6 +165,10 @@ interface StageTask {
|
|
|
165
165
|
/** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
|
|
166
166
|
* no calendar glyph, coloured by urgency (`tone`), press to edit. */
|
|
167
167
|
due: string;
|
|
168
|
+
/** The task's CHILD STEPS (`[]` = none). They render as a NESTED `TaskList`,
|
|
169
|
+
* so a step is a task: give one a due date, a menu or children of its own and
|
|
170
|
+
* it simply works. Collapsing is the app's call — render the list or don't. */
|
|
171
|
+
subtasks: { key: string; label: string; done: boolean }[];
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
// A due date N days from today — keeps the seeded urgency states (overdue / soon /
|
|
@@ -203,13 +207,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
|
|
|
203
207
|
|
|
204
208
|
// The in-flight demo record's existing checklist (a NEW record gets []).
|
|
205
209
|
const TASK_SEEDS: StageTask[] = [
|
|
206
|
-
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
|
|
207
|
-
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{ id: "
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), subtasks: [] },
|
|
211
|
+
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", subtasks: [] },
|
|
212
|
+
// two seeded NOTES — the "why it's stuck" a task carries and nothing else
|
|
213
|
+
// holds; every other row shows the add affordance instead
|
|
214
|
+
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), subtasks: [] },
|
|
215
|
+
// the one task with SUBTASKS — a booking is a sequence, and its steps carry
|
|
216
|
+
// nothing but a tick, so they stay child rows instead of four more tasks
|
|
217
|
+
// (the row opens with work left; press the chevron to close it)
|
|
218
|
+
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), subtasks: [
|
|
219
|
+
{ key: "s1", label: "Confirm the pickup window", done: true },
|
|
220
|
+
{ key: "s2", label: "Send the packing list", done: false },
|
|
221
|
+
{ key: "s3", label: "Get the booking reference", done: false },
|
|
222
|
+
{ key: "s4", label: "Share the reference with the customer", done: false },
|
|
223
|
+
] },
|
|
224
|
+
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), subtasks: [] },
|
|
225
|
+
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", subtasks: [] },
|
|
226
|
+
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), subtasks: [] },
|
|
213
227
|
];
|
|
214
228
|
|
|
215
229
|
// The assignable roster — an app feeds `useMembers()` here.
|
|
@@ -1138,11 +1152,11 @@ export function TplRecord() {
|
|
|
1138
1152
|
const addTask = () => {
|
|
1139
1153
|
const label = newTask.trim();
|
|
1140
1154
|
if (!label) return;
|
|
1141
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
|
|
1155
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", subtasks: [] }]);
|
|
1142
1156
|
setNewTask("");
|
|
1143
1157
|
};
|
|
1144
1158
|
const addSuggested = (desk: Desk, label: string) =>
|
|
1145
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
|
|
1159
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", subtasks: [] }]);
|
|
1146
1160
|
const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
|
|
1147
1161
|
const moveTask = (tid: string, desk: Desk) =>
|
|
1148
1162
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
|
|
@@ -1153,6 +1167,12 @@ export function TplRecord() {
|
|
|
1153
1167
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
|
|
1154
1168
|
const dueTask = (tid: string, due: string) =>
|
|
1155
1169
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
|
|
1170
|
+
// A child step patches by key — the kit owns the expander and the count, the
|
|
1171
|
+
// app owns nothing but the tick.
|
|
1172
|
+
const toggleSubtask = (tid: string, key: string, done: boolean) =>
|
|
1173
|
+
setTasks((prev) =>
|
|
1174
|
+
prev.map((t) => (t.id === tid ? { ...t, subtasks: t.subtasks.map((s) => (s.key === key ? { ...s, done } : s)) } : t)),
|
|
1175
|
+
);
|
|
1156
1176
|
// The Task-list grammar: a clearable Group-by + filter chips derive the
|
|
1157
1177
|
// sections; empty groups drop (except desks — the journey stays visible).
|
|
1158
1178
|
const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
|
|
@@ -1835,7 +1855,7 @@ export function TplRecord() {
|
|
|
1835
1855
|
desk's rows fully editable (the stage gates the handoff
|
|
1836
1856
|
and Billing, never task editing — planning ahead on a
|
|
1837
1857
|
later desk is normal work) */}
|
|
1838
|
-
<
|
|
1858
|
+
<TaskList>
|
|
1839
1859
|
{g.items.map((t) => {
|
|
1840
1860
|
const next = nextDeskOf(t.stage);
|
|
1841
1861
|
const menuItems: ActionMenuItem[] = [
|
|
@@ -1845,37 +1865,48 @@ export function TplRecord() {
|
|
|
1845
1865
|
{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
|
|
1846
1866
|
];
|
|
1847
1867
|
return (
|
|
1848
|
-
<
|
|
1849
|
-
|
|
1850
|
-
|
|
1868
|
+
<TaskItem key={t.id}>
|
|
1869
|
+
<TaskStatus>
|
|
1870
|
+
{/* Half-filled once any subtask is in: progress the row already
|
|
1871
|
+
holds, that a binary ring would have hidden. Display only —
|
|
1872
|
+
the parent's own `done` still wins, and still only it. */}
|
|
1851
1873
|
<CheckCircle
|
|
1852
|
-
|
|
1874
|
+
state={t.done ? "done" : t.subtasks.some((s) => s.done) ? "partial" : "none"}
|
|
1853
1875
|
onChange={(on) => toggleTask(t.id, on)}
|
|
1854
1876
|
accessibilityLabel={t.label}
|
|
1855
1877
|
/>
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
<
|
|
1878
|
+
</TaskStatus>
|
|
1879
|
+
<TaskTitle>
|
|
1880
|
+
<InlineTextInput variant="cell" value={t.label} onSave={(v) => renameTask(t.id, v)} struck={t.done} accessibilityLabel="Task title" />
|
|
1881
|
+
</TaskTitle>
|
|
1882
|
+
{/* Due + assignee as COMPACT grid cells (`variant="cell"`): the due an
|
|
1883
|
+
urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
|
|
1884
|
+
(`avatarOnly`). They sit inline on a wide record and take their own
|
|
1885
|
+
line in a drawer — the SAME JSX, no surface guess. */}
|
|
1886
|
+
<TaskFields>
|
|
1887
|
+
<View style={{ width: 104 }}>
|
|
1888
|
+
<InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
|
|
1867
1889
|
</View>
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1890
|
+
<InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
|
|
1891
|
+
</TaskFields>
|
|
1892
|
+
<TaskActions>
|
|
1893
|
+
<ActionMenu items={menuItems} accessibilityLabel={`Task options: ${t.label}`} />
|
|
1894
|
+
</TaskActions>
|
|
1895
|
+
{t.subtasks.length > 0 ? (
|
|
1896
|
+
<TaskList>
|
|
1897
|
+
{t.subtasks.map((sub) => (
|
|
1898
|
+
<TaskItem key={sub.key}>
|
|
1899
|
+
<TaskStatus>
|
|
1900
|
+
<CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
|
|
1901
|
+
</TaskStatus>
|
|
1902
|
+
<TaskTitle>
|
|
1903
|
+
<Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined}>{sub.label}</Text>
|
|
1904
|
+
</TaskTitle>
|
|
1905
|
+
</TaskItem>
|
|
1906
|
+
))}
|
|
1907
|
+
</TaskList>
|
|
1908
|
+
) : null}
|
|
1909
|
+
</TaskItem>
|
|
1879
1910
|
);
|
|
1880
1911
|
})}
|
|
1881
1912
|
{/* SUGGESTIONS are pills, not rows — tap to materialize,
|
|
@@ -1892,7 +1923,7 @@ export function TplRecord() {
|
|
|
1892
1923
|
))}
|
|
1893
1924
|
</View>
|
|
1894
1925
|
) : null}
|
|
1895
|
-
</
|
|
1926
|
+
</TaskList>
|
|
1896
1927
|
</Subsection>
|
|
1897
1928
|
);
|
|
1898
1929
|
})}
|
|
@@ -2213,7 +2244,7 @@ export function TplRecord() {
|
|
|
2213
2244
|
the geometry (row height, control/title alignment, the
|
|
2214
2245
|
meta/expansion indent); hand-rolling this anatomy is how
|
|
2215
2246
|
alignment drifts. controlWidth 24 = CheckboxInput. */}
|
|
2216
|
-
<
|
|
2247
|
+
<TaskList controlWidth={24}>
|
|
2217
2248
|
{shown.map((f) => {
|
|
2218
2249
|
// READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
|
|
2219
2250
|
// missing (muted at rest — discoverable without checking; warning once
|
|
@@ -2225,16 +2256,23 @@ export function TplRecord() {
|
|
|
2225
2256
|
const missing = missingOf(f);
|
|
2226
2257
|
const checked = chosenForms.has(f.id);
|
|
2227
2258
|
return (
|
|
2228
|
-
<
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2259
|
+
<TaskItem key={f.id}>
|
|
2260
|
+
<TaskStatus>
|
|
2261
|
+
<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
|
|
2262
|
+
</TaskStatus>
|
|
2263
|
+
<TaskTitle>
|
|
2264
|
+
<Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
|
|
2265
|
+
</TaskTitle>
|
|
2266
|
+
{missing.length > 0 ? (
|
|
2267
|
+
<TaskFields>
|
|
2232
2268
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
|
|
2233
2269
|
<Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
|
|
2234
2270
|
<Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
|
|
2235
2271
|
</View>
|
|
2236
|
-
|
|
2237
|
-
|
|
2272
|
+
</TaskFields>
|
|
2273
|
+
) : null}
|
|
2274
|
+
{checked && missing.length > 0 ? (
|
|
2275
|
+
<TaskDetail>
|
|
2238
2276
|
<Inset>
|
|
2239
2277
|
<Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
|
|
2240
2278
|
{missing.map((k) => (
|
|
@@ -2256,13 +2294,12 @@ export function TplRecord() {
|
|
|
2256
2294
|
/>
|
|
2257
2295
|
</View>
|
|
2258
2296
|
</Inset>
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
</ChecklistRow>
|
|
2297
|
+
</TaskDetail>
|
|
2298
|
+
) : null}
|
|
2299
|
+
</TaskItem>
|
|
2263
2300
|
);
|
|
2264
2301
|
})}
|
|
2265
|
-
</
|
|
2302
|
+
</TaskList>
|
|
2266
2303
|
{hidden > 0 ? (
|
|
2267
2304
|
<View style={{ flexDirection: "row" }}>
|
|
2268
2305
|
<Button
|
|
@@ -366,7 +366,13 @@ export function TplTaskBoard() {
|
|
|
366
366
|
columns={columns}
|
|
367
367
|
groups={groups}
|
|
368
368
|
getRowKey={(t) => t.id}
|
|
369
|
-
leading={{
|
|
369
|
+
leading={{
|
|
370
|
+
width: 20,
|
|
371
|
+
// The ring carries all three positions this board already models: a task at "doing"
|
|
372
|
+
// is not finished and is not untouched, and a binary ring would call it untouched.
|
|
373
|
+
// Clicking still only finishes or reopens — "doing" is set from the status column.
|
|
374
|
+
render: (t) => <CheckCircle state={t.status === "done" ? "done" : t.status === "doing" ? "partial" : "none"} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} />,
|
|
375
|
+
}}
|
|
370
376
|
sort={sort}
|
|
371
377
|
onSort={onSort}
|
|
372
378
|
collapsed={collapsed}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./detail_row": "./src/detail_row.tsx",
|
|
183
183
|
"./record_summary": "./src/record_summary.tsx",
|
|
184
184
|
"./capture_row": "./src/capture_row.tsx",
|
|
185
|
-
"./
|
|
185
|
+
"./task": "./src/task.tsx",
|
|
186
186
|
"./suggestion_chip": "./src/suggestion_chip.tsx",
|
|
187
187
|
"./danger_zone": "./src/danger_zone.tsx",
|
|
188
188
|
"./scan_field": "./src/scan_field.tsx",
|
package/src/capture_row.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StyleSheet, View } from "react-native";
|
|
2
2
|
import { Button } from "./button";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { Icon } from "./icon";
|
|
4
5
|
import { useLoticsLocale } from "./locale";
|
|
5
6
|
import { TextInputField } from "./text_input_field";
|
|
6
7
|
|
|
@@ -30,7 +31,11 @@ export function CaptureRow(props: CaptureRowProps) {
|
|
|
30
31
|
const labels = useLoticsLocale().inline;
|
|
31
32
|
return (
|
|
32
33
|
<View style={styles.row}>
|
|
33
|
-
|
|
34
|
+
{/* The ghost ring holds the task column, and the plus says what pressing here does —
|
|
35
|
+
a dashed circle alone reads as an unchecked task, not as an invitation. */}
|
|
36
|
+
<View style={styles.emptyRing}>
|
|
37
|
+
<Icon name="plus" size={12} color={colors.zinc[400]} />
|
|
38
|
+
</View>
|
|
34
39
|
<View style={styles.field}>
|
|
35
40
|
<View style={styles.grow}>
|
|
36
41
|
<TextInputField
|
|
@@ -50,7 +55,7 @@ export function CaptureRow(props: CaptureRowProps) {
|
|
|
50
55
|
|
|
51
56
|
const styles = StyleSheet.create({
|
|
52
57
|
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
53
|
-
emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
|
|
58
|
+
emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed", alignItems: "center", justifyContent: "center" },
|
|
54
59
|
field: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8 },
|
|
55
60
|
grow: { flex: 1 },
|
|
56
61
|
// Borderless, matching an inline title's resting inset so the invitation
|
package/src/check_circle.tsx
CHANGED
|
@@ -4,11 +4,26 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Three positions, not two. `partial` is the state a BINARY ring has to lie about — a step
|
|
9
|
+
* whose vocabulary reads "No portal account → Account created → Filed online" is not finished
|
|
10
|
+
* at "Account created", but it is emphatically not untouched either, and an empty ring says
|
|
11
|
+
* untouched. A column of rings is the fastest scan on a task surface, so the one row shape it
|
|
12
|
+
* reports wrongly is the one the reader most needs to find.
|
|
13
|
+
*/
|
|
14
|
+
export type CheckCircleState = "none" | "partial" | "done";
|
|
15
|
+
|
|
7
16
|
export interface CheckCircleProps {
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
state: CheckCircleState;
|
|
18
|
+
/**
|
|
19
|
+
* Completion handler — `true` to finish, `false` to reopen. Binary ON PURPOSE even though
|
|
20
|
+
* the ring shows three: the ring's meaning has to stay constant ("is this finished") or a
|
|
21
|
+
* click becomes unpredictable. `partial` is reached through whatever NAMES it — a status
|
|
22
|
+
* cell in the step's own words, or children ticking off — never by cycling this control.
|
|
23
|
+
* Omit for a read-only ring (shows state, not pressable).
|
|
24
|
+
*/
|
|
25
|
+
onChange?: (done: boolean) => void;
|
|
26
|
+
/** Fill colour when done (and the half-fill when partial). Default zinc-900. */
|
|
12
27
|
color?: string;
|
|
13
28
|
/** Diameter. Default 20. */
|
|
14
29
|
size?: number;
|
|
@@ -17,22 +32,43 @@ export interface CheckCircleProps {
|
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
/**
|
|
20
|
-
* A circular completion
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
35
|
+
* A circular completion control — an empty ring that fills with a spring + a white check when
|
|
36
|
+
* done (the iOS-Reminders / Linear gesture), distinct from the square form `Checkbox`. The
|
|
37
|
+
* signature control for a task / to-do / checklist item; pair it with a title that strikes +
|
|
38
|
+
* mutes on the same `done`. It stops the press, so it works inside a pressable row. Read-only
|
|
39
|
+
* (no `onChange`) shows the state without a tap.
|
|
40
|
+
*
|
|
41
|
+
* `partial` half-fills it. Deliberately MONOCHROME at every position: a task row that also
|
|
42
|
+
* carries a coloured status would otherwise double-code the same fact, and the ring's job is
|
|
43
|
+
* "how far", not "which state".
|
|
26
44
|
*/
|
|
27
45
|
export function CheckCircle(props: CheckCircleProps) {
|
|
28
|
-
const {
|
|
29
|
-
const
|
|
46
|
+
const { state, onChange, color = colors.zinc[900], size = 20, disabled, accessibilityLabel } = props;
|
|
47
|
+
const done = state === "done";
|
|
48
|
+
const v = useRef(new Animated.Value(done ? 1 : 0)).current;
|
|
30
49
|
useEffect(() => {
|
|
31
|
-
Animated.spring(v, { toValue:
|
|
32
|
-
}, [
|
|
50
|
+
Animated.spring(v, { toValue: done ? 1 : 0, useNativeDriver: false, friction: 6, tension: 160 }).start();
|
|
51
|
+
}, [done, v]);
|
|
33
52
|
|
|
53
|
+
// Sits INSIDE the 1.5px border so the outline stays whole around it — a filled half in a
|
|
54
|
+
// complete ring reads as progress, where a half-covered ring would read as a rendering bug.
|
|
55
|
+
const half = (size - 3) / 2;
|
|
34
56
|
const face = (borderColor: string) => (
|
|
35
57
|
<View style={[styles.ring, { width: size, height: size, borderRadius: size / 2, borderColor }]}>
|
|
58
|
+
{state === "partial" ? (
|
|
59
|
+
<View
|
|
60
|
+
style={{
|
|
61
|
+
position: "absolute",
|
|
62
|
+
left: 0,
|
|
63
|
+
top: 0,
|
|
64
|
+
width: half,
|
|
65
|
+
height: size - 3,
|
|
66
|
+
borderTopLeftRadius: half,
|
|
67
|
+
borderBottomLeftRadius: half,
|
|
68
|
+
backgroundColor: color,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
36
72
|
<Animated.View style={{ position: "absolute", width: size, height: size, borderRadius: size / 2, backgroundColor: color, transform: [{ scale: v }], opacity: v }} />
|
|
37
73
|
<Animated.View style={{ opacity: v, transform: [{ scale: v }] }}>
|
|
38
74
|
<Icon name="check" size={Math.round(size * 0.62)} color={colors.white} />
|
|
@@ -40,22 +76,30 @@ export function CheckCircle(props: CheckCircleProps) {
|
|
|
40
76
|
</View>
|
|
41
77
|
);
|
|
42
78
|
|
|
79
|
+
// Anything past "none" is engaged, so the outline darkens — the half-fill alone was too
|
|
80
|
+
// quiet to catch in a column.
|
|
81
|
+
const resting = state === "none" ? colors.zinc[300] : color;
|
|
82
|
+
|
|
43
83
|
if (!onChange) {
|
|
44
84
|
return (
|
|
45
85
|
<View accessibilityRole="image" accessibilityLabel={accessibilityLabel} style={[styles.tap, { width: size, height: size }, disabled ? styles.disabled : null]}>
|
|
46
|
-
{face(
|
|
86
|
+
{face(resting)}
|
|
47
87
|
</View>
|
|
48
88
|
);
|
|
49
89
|
}
|
|
50
90
|
|
|
51
91
|
const press = (e: GestureResponderEvent) => {
|
|
52
92
|
e?.stopPropagation?.();
|
|
53
|
-
if (!disabled) onChange(!
|
|
93
|
+
if (!disabled) onChange(!done);
|
|
54
94
|
};
|
|
55
95
|
return (
|
|
56
96
|
<FocusRingPressable
|
|
57
97
|
accessibilityRole="checkbox"
|
|
58
|
-
|
|
98
|
+
// The W3C prop, NOT `accessibilityState` — this react-native-web build drops the latter
|
|
99
|
+
// silently, so every ring shipped a `role="checkbox"` that never announced whether it was
|
|
100
|
+
// checked. "mixed" is aria's own tri-state, which is exactly what `partial` is.
|
|
101
|
+
aria-checked={done ? true : state === "partial" ? "mixed" : false}
|
|
102
|
+
aria-disabled={disabled}
|
|
59
103
|
accessibilityLabel={accessibilityLabel}
|
|
60
104
|
disabled={disabled}
|
|
61
105
|
onPress={press}
|
|
@@ -64,7 +108,7 @@ export function CheckCircle(props: CheckCircleProps) {
|
|
|
64
108
|
// the keyboard ring hugs the toggle instead of boxing it.
|
|
65
109
|
style={[styles.tap, { width: size, height: size, borderRadius: size / 2 }, disabled ? styles.disabled : null]}
|
|
66
110
|
>
|
|
67
|
-
{({ hovered }: { hovered?: boolean }) => face(
|
|
111
|
+
{({ hovered }: { hovered?: boolean }) => face(state === "none" && hovered ? colors.zinc[500] : resting)}
|
|
68
112
|
</FocusRingPressable>
|
|
69
113
|
);
|
|
70
114
|
}
|
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,
|