@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.
@@ -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 { Checklist, ChecklistRow } from "@lotics/ui/checklist";
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
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2) },
209
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1) },
210
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6) },
211
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "" },
212
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14) },
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
- <Checklist trailingWidth={152}>
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
- <ChecklistRow
1849
- key={t.id}
1850
- control={
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
- checked={t.done}
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
- trailing={
1858
- // Due + assignee as COMPACT grid cells (`variant="cell"`): the due a
1859
- // urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
1860
- // (`avatarOnly` no name/chevron, a dashed add-ghost when unset). Both
1861
- // hug within the list's `trailingWidth` so the title never overlaps.
1862
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
1863
- <View style={{ width: 104 }}>
1864
- <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}`} />
1865
- </View>
1866
- <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
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
- menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1870
- >
1871
- <InlineTextInput
1872
- variant="cell"
1873
- value={t.label}
1874
- onSave={(v) => renameTask(t.id, v)}
1875
- struck={t.done}
1876
- accessibilityLabel="Task title"
1877
- />
1878
- </ChecklistRow>
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
- </Checklist>
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
- <Checklist controlWidth={24}>
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
- <ChecklistRow
2229
- key={f.id}
2230
- control={<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />}
2231
- meta={missing.length > 0 ? (
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
- ) : undefined}
2237
- expansion={checked && missing.length > 0 ? (
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
- ) : undefined}
2260
- >
2261
- <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2262
- </ChecklistRow>
2297
+ </TaskDetail>
2298
+ ) : null}
2299
+ </TaskItem>
2263
2300
  );
2264
2301
  })}
2265
- </Checklist>
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={{ width: 20, render: (t) => <CheckCircle checked={t.status === "done"} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} /> }}
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": "16.1.0",
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
- "./checklist": "./src/checklist.tsx",
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",
@@ -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
- <View style={styles.emptyRing} />
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
@@ -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
- checked: boolean;
9
- /** Toggle handler. Omit for a read-only ring (shows state, not pressable). */
10
- onChange?: (checked: boolean) => void;
11
- /** Fill colour when checked. Default zinc-900 (pass a list/project colour to tint it). */
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 toggle — an empty ring that fills with a spring + a white
21
- * check when checked (the iOS-Reminders / Linear "done" gesture), distinct from
22
- * the square form `Checkbox`. The signature control for a task / to-do / checklist
23
- * item; pair it with a title that strikes + mutes on the same `checked`. It stops
24
- * the press, so it works inside a pressable row. Read-only (no `onChange`) shows
25
- * the state without a tap.
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 { checked, onChange, color = colors.zinc[900], size = 20, disabled, accessibilityLabel } = props;
29
- const v = useRef(new Animated.Value(checked ? 1 : 0)).current;
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: checked ? 1 : 0, useNativeDriver: false, friction: 6, tension: 160 }).start();
32
- }, [checked, v]);
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(checked ? color : colors.zinc[300])}
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(!checked);
93
+ if (!disabled) onChange(!done);
54
94
  };
55
95
  return (
56
96
  <FocusRingPressable
57
97
  accessibilityRole="checkbox"
58
- accessibilityState={{ checked, disabled }}
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(checked ? color : hovered ? colors.zinc[500] : colors.zinc[300])}
111
+ {({ hovered }: { hovered?: boolean }) => face(state === "none" && hovered ? colors.zinc[500] : resting)}
68
112
  </FocusRingPressable>
69
113
  );
70
114
  }
@@ -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}
@@ -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
- <Text size={labelSize} color="muted" numberOfLines={1}>
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
- <Text weight="medium" numberOfLines={1}>
195
- {label}
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.
@@ -19,7 +19,9 @@ export function FormDatePicker(props: FormDatePickerProps) {
19
19
  optional={optional}
20
20
  optionalLabel={optionalLabel}
21
21
  >
22
- <DatePicker {...datePickerProps} />
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
  }
@@ -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} numberOfLines={1} weight="medium">
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,