@lotics/ui 16.2.0 → 17.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +47 -0
- package/docs/catalog.md +38 -53
- package/docs/data_entry.md +54 -26
- package/docs/templates.md +7 -8
- package/examples/tpl_item_list.tsx +47 -44
- package/examples/tpl_record.tsx +70 -79
- package/examples/tpl_task_board.tsx +7 -1
- package/package.json +3 -3
- package/src/capture_row.tsx +7 -2
- package/src/check_circle.tsx +62 -18
- package/src/checkbox.tsx +7 -1
- package/src/locale.tsx +0 -24
- package/src/task.tsx +287 -0
- package/src/checklist.tsx +0 -377
package/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,15 +165,9 @@ 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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
note: string;
|
|
172
|
-
/** The task's CHILD STEPS (`[]` = none, and then nothing renders — not even
|
|
173
|
-
* the expander). The `ChecklistRow` `subtasks` contract owns the whole
|
|
174
|
-
* affordance: the chevron + remaining count on the parent, the open/closed
|
|
175
|
-
* state, and the indented child rows. A step that needs its own note, due
|
|
176
|
-
* date, or assignee is a TASK — give it a row, not a subtask. */
|
|
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. */
|
|
177
171
|
subtasks: { key: string; label: string; done: boolean }[];
|
|
178
172
|
}
|
|
179
173
|
|
|
@@ -213,23 +207,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
|
|
|
213
207
|
|
|
214
208
|
// The in-flight demo record's existing checklist (a NEW record gets []).
|
|
215
209
|
const TASK_SEEDS: StageTask[] = [
|
|
216
|
-
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10),
|
|
217
|
-
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "",
|
|
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: [] },
|
|
218
212
|
// two seeded NOTES — the "why it's stuck" a task carries and nothing else
|
|
219
213
|
// holds; every other row shows the add affordance instead
|
|
220
|
-
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2),
|
|
214
|
+
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), subtasks: [] },
|
|
221
215
|
// the one task with SUBTASKS — a booking is a sequence, and its steps carry
|
|
222
216
|
// nothing but a tick, so they stay child rows instead of four more tasks
|
|
223
217
|
// (the row opens with work left; press the chevron to close it)
|
|
224
|
-
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1),
|
|
218
|
+
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), subtasks: [
|
|
225
219
|
{ key: "s1", label: "Confirm the pickup window", done: true },
|
|
226
220
|
{ key: "s2", label: "Send the packing list", done: false },
|
|
227
221
|
{ key: "s3", label: "Get the booking reference", done: false },
|
|
228
222
|
{ key: "s4", label: "Share the reference with the customer", done: false },
|
|
229
223
|
] },
|
|
230
|
-
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6),
|
|
231
|
-
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "",
|
|
232
|
-
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14),
|
|
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: [] },
|
|
233
227
|
];
|
|
234
228
|
|
|
235
229
|
// The assignable roster — an app feeds `useMembers()` here.
|
|
@@ -1158,11 +1152,11 @@ export function TplRecord() {
|
|
|
1158
1152
|
const addTask = () => {
|
|
1159
1153
|
const label = newTask.trim();
|
|
1160
1154
|
if (!label) return;
|
|
1161
|
-
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: [] }]);
|
|
1162
1156
|
setNewTask("");
|
|
1163
1157
|
};
|
|
1164
1158
|
const addSuggested = (desk: Desk, label: string) =>
|
|
1165
|
-
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: [] }]);
|
|
1166
1160
|
const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
|
|
1167
1161
|
const moveTask = (tid: string, desk: Desk) =>
|
|
1168
1162
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
|
|
@@ -1173,9 +1167,6 @@ export function TplRecord() {
|
|
|
1173
1167
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
|
|
1174
1168
|
const dueTask = (tid: string, due: string) =>
|
|
1175
1169
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
|
|
1176
|
-
// Saving an EMPTY note is the removal — one write path, no separate delete.
|
|
1177
|
-
const noteTask = (tid: string, note: string) =>
|
|
1178
|
-
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, note } : t)));
|
|
1179
1170
|
// A child step patches by key — the kit owns the expander and the count, the
|
|
1180
1171
|
// app owns nothing but the tick.
|
|
1181
1172
|
const toggleSubtask = (tid: string, key: string, done: boolean) =>
|
|
@@ -1864,7 +1855,7 @@ export function TplRecord() {
|
|
|
1864
1855
|
desk's rows fully editable (the stage gates the handoff
|
|
1865
1856
|
and Billing, never task editing — planning ahead on a
|
|
1866
1857
|
later desk is normal work) */}
|
|
1867
|
-
<
|
|
1858
|
+
<TaskList>
|
|
1868
1859
|
{g.items.map((t) => {
|
|
1869
1860
|
const next = nextDeskOf(t.stage);
|
|
1870
1861
|
const menuItems: ActionMenuItem[] = [
|
|
@@ -1874,54 +1865,48 @@ export function TplRecord() {
|
|
|
1874
1865
|
{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
|
|
1875
1866
|
];
|
|
1876
1867
|
return (
|
|
1877
|
-
<
|
|
1878
|
-
|
|
1879
|
-
|
|
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. */}
|
|
1880
1873
|
<CheckCircle
|
|
1881
|
-
|
|
1874
|
+
state={t.done ? "done" : t.subtasks.some((s) => s.done) ? "partial" : "none"}
|
|
1882
1875
|
onChange={(on) => toggleTask(t.id, on)}
|
|
1883
1876
|
accessibilityLabel={t.label}
|
|
1884
1877
|
/>
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
<
|
|
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}`} />
|
|
1896
1889
|
</View>
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
>
|
|
1917
|
-
<InlineTextInput
|
|
1918
|
-
variant="cell"
|
|
1919
|
-
value={t.label}
|
|
1920
|
-
onSave={(v) => renameTask(t.id, v)}
|
|
1921
|
-
struck={t.done}
|
|
1922
|
-
accessibilityLabel="Task title"
|
|
1923
|
-
/>
|
|
1924
|
-
</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>
|
|
1925
1910
|
);
|
|
1926
1911
|
})}
|
|
1927
1912
|
{/* SUGGESTIONS are pills, not rows — tap to materialize,
|
|
@@ -1938,7 +1923,7 @@ export function TplRecord() {
|
|
|
1938
1923
|
))}
|
|
1939
1924
|
</View>
|
|
1940
1925
|
) : null}
|
|
1941
|
-
</
|
|
1926
|
+
</TaskList>
|
|
1942
1927
|
</Subsection>
|
|
1943
1928
|
);
|
|
1944
1929
|
})}
|
|
@@ -2259,7 +2244,7 @@ export function TplRecord() {
|
|
|
2259
2244
|
the geometry (row height, control/title alignment, the
|
|
2260
2245
|
meta/expansion indent); hand-rolling this anatomy is how
|
|
2261
2246
|
alignment drifts. controlWidth 24 = CheckboxInput. */}
|
|
2262
|
-
<
|
|
2247
|
+
<TaskList controlWidth={24}>
|
|
2263
2248
|
{shown.map((f) => {
|
|
2264
2249
|
// READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
|
|
2265
2250
|
// missing (muted at rest — discoverable without checking; warning once
|
|
@@ -2271,16 +2256,23 @@ export function TplRecord() {
|
|
|
2271
2256
|
const missing = missingOf(f);
|
|
2272
2257
|
const checked = chosenForms.has(f.id);
|
|
2273
2258
|
return (
|
|
2274
|
-
<
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
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>
|
|
2278
2268
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
|
|
2279
2269
|
<Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
|
|
2280
2270
|
<Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
|
|
2281
2271
|
</View>
|
|
2282
|
-
|
|
2283
|
-
|
|
2272
|
+
</TaskFields>
|
|
2273
|
+
) : null}
|
|
2274
|
+
{checked && missing.length > 0 ? (
|
|
2275
|
+
<TaskDetail>
|
|
2284
2276
|
<Inset>
|
|
2285
2277
|
<Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
|
|
2286
2278
|
{missing.map((k) => (
|
|
@@ -2302,13 +2294,12 @@ export function TplRecord() {
|
|
|
2302
2294
|
/>
|
|
2303
2295
|
</View>
|
|
2304
2296
|
</Inset>
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
</ChecklistRow>
|
|
2297
|
+
</TaskDetail>
|
|
2298
|
+
) : null}
|
|
2299
|
+
</TaskItem>
|
|
2309
2300
|
);
|
|
2310
2301
|
})}
|
|
2311
|
-
</
|
|
2302
|
+
</TaskList>
|
|
2312
2303
|
{hidden > 0 ? (
|
|
2313
2304
|
<View style={{ flexDirection: "row" }}>
|
|
2314
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.1",
|
|
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",
|
|
@@ -314,4 +314,4 @@
|
|
|
314
314
|
"unified": "^11.0.5",
|
|
315
315
|
"vite": "^7.2.4"
|
|
316
316
|
}
|
|
317
|
-
}
|
|
317
|
+
}
|
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/checkbox.tsx
CHANGED
|
@@ -9,6 +9,12 @@ interface CheckboxProps {
|
|
|
9
9
|
|
|
10
10
|
export function Checkbox(props: CheckboxProps) {
|
|
11
11
|
const { checked, indeterminate } = props;
|
|
12
|
+
// `indeterminate` FILLS the box, exactly as `checked` does — the mixed state is a painted
|
|
13
|
+
// box with a dash on every platform that has one, and the dash is drawn in `white`. Keying
|
|
14
|
+
// the fill on `checked` alone meant a mixed-but-unchecked control drew a white dash on the
|
|
15
|
+
// white background: invisible. Every `selectAll` therefore reported "nothing selected" while
|
|
16
|
+
// rows were selected — under-reporting, which is the worse direction to be wrong in.
|
|
17
|
+
const filled = checked || indeterminate === true;
|
|
12
18
|
|
|
13
19
|
return (
|
|
14
20
|
<View
|
|
@@ -19,7 +25,7 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
19
25
|
alignItems: "center",
|
|
20
26
|
borderWidth: 1,
|
|
21
27
|
borderColor: colors.border,
|
|
22
|
-
backgroundColor:
|
|
28
|
+
backgroundColor: filled ? colors.zinc["800"] : colors.background,
|
|
23
29
|
borderRadius: 6,
|
|
24
30
|
}}
|
|
25
31
|
>
|
package/src/locale.tsx
CHANGED
|
@@ -69,18 +69,6 @@ export interface LoticsLocale {
|
|
|
69
69
|
trendFooter: { up: string; down: string };
|
|
70
70
|
/** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
|
|
71
71
|
suggestionChip: { add: (label: string) => string; dismiss: string };
|
|
72
|
-
/** `ChecklistRow`'s per-row NOTE: the add affordance's name/tooltip, the note
|
|
73
|
-
* line's press name (it WRAPS the note text so the line is announced with
|
|
74
|
-
* it), and the editor's placeholder — plus the SUBTASK expander's name,
|
|
75
|
-
* given the count of unchecked children. The expander's open/closed state
|
|
76
|
-
* rides `aria-expanded`, so `subtasks` must NOT name it: one stable name,
|
|
77
|
-
* or every toggle re-announces the control. */
|
|
78
|
-
checklist: {
|
|
79
|
-
addNote: string;
|
|
80
|
-
editNote: (note: string) => string;
|
|
81
|
-
notePlaceholder: string;
|
|
82
|
-
subtasks: (missing: number) => string;
|
|
83
|
-
};
|
|
84
72
|
/** `Confidence`: the full level phrase ("High confidence" …). */
|
|
85
73
|
confidence: ConfidenceLabels;
|
|
86
74
|
/** `Finding`: the severity badge word ("Critical" …). */
|
|
@@ -188,12 +176,6 @@ export const en: LoticsLocale = {
|
|
|
188
176
|
chip: { remove: "Remove" },
|
|
189
177
|
trendFooter: { up: "Up", down: "Down" },
|
|
190
178
|
suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
|
|
191
|
-
checklist: {
|
|
192
|
-
addNote: "Add note",
|
|
193
|
-
editNote: (note) => `Edit note: ${note}`,
|
|
194
|
-
notePlaceholder: "Add a note…",
|
|
195
|
-
subtasks: (missing) => (missing > 0 ? `Subtasks: ${missing} left` : "Subtasks: all done"),
|
|
196
|
-
},
|
|
197
179
|
confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
|
|
198
180
|
remainderMeter: {
|
|
199
181
|
applied: (allocated, total) => `${allocated} of ${total} applied`,
|
|
@@ -292,12 +274,6 @@ export const vi: LoticsLocale = {
|
|
|
292
274
|
chip: { remove: "Xóa" },
|
|
293
275
|
trendFooter: { up: "Tăng", down: "Giảm" },
|
|
294
276
|
suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
|
|
295
|
-
checklist: {
|
|
296
|
-
addNote: "Thêm ghi chú",
|
|
297
|
-
editNote: (note) => `Sửa ghi chú: ${note}`,
|
|
298
|
-
notePlaceholder: "Thêm ghi chú…",
|
|
299
|
-
subtasks: (missing) => (missing > 0 ? `Việc con: còn ${missing}` : "Việc con: đã xong"),
|
|
300
|
-
},
|
|
301
277
|
confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
|
|
302
278
|
remainderMeter: {
|
|
303
279
|
applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
|