@lotics/ui 46.2.0 → 46.8.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 +38 -1
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +217 -21
- package/docs/composition.md +74 -6
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +93 -30
- package/docs/testing.md +6 -0
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -258
- package/src/accordion.tsx +7 -1
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/reference_field.tsx +36 -13
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
- package/src/line_chart_labels.ts +0 -32
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
4
|
+
import { Board, BoardCard, BoardColumn } from "@lotics/ui/board";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { colors } from "@lotics/ui/colors";
|
|
7
|
+
import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
|
|
8
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
9
|
+
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
10
|
+
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
11
|
+
import { MemberChip } from "@lotics/ui/member_chip";
|
|
12
|
+
import { OptionBadge } from "@lotics/ui/option_badge";
|
|
13
|
+
import { OptionList } from "@lotics/ui/option_list";
|
|
14
|
+
import { SearchInput } from "@lotics/ui/search_input";
|
|
15
|
+
import { SummaryLine } from "@lotics/ui/summary_line";
|
|
16
|
+
import { Text } from "@lotics/ui/text";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* THE column board — a surface whose subject is ADVANCING work between piles.
|
|
20
|
+
*
|
|
21
|
+
* ## Why this is not `tpl_task_board`
|
|
22
|
+
*
|
|
23
|
+
* They look adjacent and they are not. `tpl_task_board` is a `DataGrid`: bands
|
|
24
|
+
* of ROWS with an inline editor in every cell, for a reader who is COMPARING
|
|
25
|
+
* work and correcting its fields — a deadline here, an owner there. Nothing
|
|
26
|
+
* moves; the grid is a spreadsheet with opinions.
|
|
27
|
+
*
|
|
28
|
+
* This shape exists for the opposite act. The columns ARE the stages, the pile
|
|
29
|
+
* sizes are themselves the report, and the only thing the reader does is send a
|
|
30
|
+
* card onward. Reach for it when the question is *"what is stuck, and what moves
|
|
31
|
+
* next?"* rather than *"which of these is wrong?"*.
|
|
32
|
+
*
|
|
33
|
+
* Getting this backwards is not hypothetical: an app hand-rolled a 280px column
|
|
34
|
+
* because a reviewer read `tpl_task_board`'s NAME and told its author the kit
|
|
35
|
+
* already shipped a board. It did not. Read a template's source, never its name.
|
|
36
|
+
*
|
|
37
|
+
* ## What a board owes its reader
|
|
38
|
+
*
|
|
39
|
+
* - **The move is reachable by KEYBOARD.** Every card carries a control whose
|
|
40
|
+
* items name their destinations, so advancing work never requires a pointer.
|
|
41
|
+
* Drag is layered on the same declared list — the grip drags, the drop
|
|
42
|
+
* hit-tests the column under the pointer and is refused unless the card
|
|
43
|
+
* already declared it, so the two paths cannot disagree about what is legal.
|
|
44
|
+
* - **A column states its size as a PROP.** Never formatted into the label: the
|
|
45
|
+
* count is what makes a pile a report, and a number inside a heading is a
|
|
46
|
+
* second copy that goes stale.
|
|
47
|
+
* - **The BOARD scrolls sideways, never the page.** A wide shape earns its place
|
|
48
|
+
* by owning its own overflow; a page that scrolls horizontally has lost its
|
|
49
|
+
* left edge.
|
|
50
|
+
* - **A card is a DOOR.** Pressing it opens the record, exactly as a register
|
|
51
|
+
* row does — the board is another way into the same surface, never a second
|
|
52
|
+
* one. What a card SHOWS is the minimum for deciding whether to move it.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
type Stage = "intake" | "review" | "approved" | "issued";
|
|
56
|
+
|
|
57
|
+
interface Doc {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
ref: string;
|
|
61
|
+
owner: string;
|
|
62
|
+
due: string | null;
|
|
63
|
+
stage: Stage;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const STAGES: { key: Stage; label: string; color: "zinc" | "amber" | "blue" | "green" }[] = [
|
|
67
|
+
{ key: "intake", label: "Received", color: "zinc" },
|
|
68
|
+
{ key: "review", label: "In review", color: "amber" },
|
|
69
|
+
{ key: "approved", label: "Approved", color: "blue" },
|
|
70
|
+
{ key: "issued", label: "Issued", color: "green" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const SEED: Doc[] = [
|
|
74
|
+
{ id: "d1", title: "Carrier rate agreement 2026", ref: "DOC-2026-0041", owner: "Trần Thị Ngọc Ánh", due: "2026-08-24", stage: "intake" },
|
|
75
|
+
{ id: "d2", title: "Warehouse lease — Bay 4", ref: "DOC-2026-0042", owner: "Nguyễn Minh Đức", due: "2026-09-02", stage: "intake" },
|
|
76
|
+
{ id: "d3", title: "Customs power of attorney", ref: "DOC-2026-0043", owner: "Lê Thu Hằng", due: null, stage: "intake" },
|
|
77
|
+
{ id: "d4", title: "Insurance renewal — fleet", ref: "DOC-2026-0038", owner: "Trần Thị Ngọc Ánh", due: "2026-08-28", stage: "review" },
|
|
78
|
+
{ id: "d5", title: "Subcontractor NDA", ref: "DOC-2026-0039", owner: "Phạm Thị Lan", due: "2026-09-15", stage: "review" },
|
|
79
|
+
{ id: "d6", title: "Port handling addendum", ref: "DOC-2026-0035", owner: "Nguyễn Minh Đức", due: "2026-09-08", stage: "approved" },
|
|
80
|
+
{ id: "d7", title: "Annual safety declaration", ref: "DOC-2026-0031", owner: "Lê Thu Hằng", due: "2026-08-19", stage: "issued" },
|
|
81
|
+
{ id: "d8", title: "Depot access permit", ref: "DOC-2026-0029", owner: "Phạm Thị Lan", due: "2026-10-01", stage: "issued" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const OWNERS = [...new Set(SEED.map((d) => d.owner))].map((o) => ({ value: o, label: o }));
|
|
85
|
+
|
|
86
|
+
/** The template's "now", so the countdowns are stable in a gallery. */
|
|
87
|
+
const NOW = new Date("2026-08-30T00:00:00Z");
|
|
88
|
+
const DEADLINE_WORDS = {
|
|
89
|
+
overdue: (d: number) => `${d} day${d === 1 ? "" : "s"} overdue`,
|
|
90
|
+
today: "Due today",
|
|
91
|
+
tomorrow: "Due tomorrow",
|
|
92
|
+
inDays: (d: number) => `${d} days left`,
|
|
93
|
+
};
|
|
94
|
+
/** One helper, so a card and the record it opens cannot word the same date
|
|
95
|
+
* differently — the annotation carries its own tone with it. */
|
|
96
|
+
const dueProps = (iso: string, done: boolean) =>
|
|
97
|
+
done ? { color: "muted" as const, children: "Issued" } : (() => {
|
|
98
|
+
const a = deadlineAnnotation(daysUntil(new Date(iso), NOW), DEADLINE_WORDS);
|
|
99
|
+
const text = a.error ?? a.warning ?? a.description ?? "";
|
|
100
|
+
const color: "danger" | "warning" | "muted" = a.error ? "danger" : a.warning ? "warning" : "muted";
|
|
101
|
+
return { color, children: text };
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
export function TplBoard() {
|
|
105
|
+
const [docs, setDocs] = useState(SEED);
|
|
106
|
+
const [query, setQuery] = useState("");
|
|
107
|
+
const [owners, setOwners] = useState<string[]>([]);
|
|
108
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
109
|
+
|
|
110
|
+
const shown = useMemo(() => {
|
|
111
|
+
const q = query.trim().toLowerCase();
|
|
112
|
+
return docs.filter(
|
|
113
|
+
(d) =>
|
|
114
|
+
(!q || d.title.toLowerCase().includes(q) || d.ref.toLowerCase().includes(q)) &&
|
|
115
|
+
(owners.length === 0 || owners.includes(d.owner)),
|
|
116
|
+
);
|
|
117
|
+
}, [docs, query, owners]);
|
|
118
|
+
|
|
119
|
+
const move = (id: string, to: string) =>
|
|
120
|
+
setDocs((all) => all.map((d) => (d.id === id ? { ...d, stage: to as Stage } : d)));
|
|
121
|
+
|
|
122
|
+
const record = docs.find((d) => d.id === open) ?? null;
|
|
123
|
+
const overdue = shown.filter((d) => d.due != null && d.due < "2026-08-30" && d.stage !== "issued").length;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<ScrollView
|
|
128
|
+
style={{ flex: 1, backgroundColor: colors.white }}
|
|
129
|
+
contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}
|
|
130
|
+
>
|
|
131
|
+
<View style={{ maxWidth: 1240, width: "100%", alignSelf: "center", gap: 16 }}>
|
|
132
|
+
<Text size="xxl" weight="semibold">
|
|
133
|
+
Document approvals
|
|
134
|
+
</Text>
|
|
135
|
+
|
|
136
|
+
{/* Search leftmost, then the facets — the same toolbar order a register
|
|
137
|
+
uses, because the board is a VIEW of the same population and the
|
|
138
|
+
reader should not relearn where the search box lives. */}
|
|
139
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
140
|
+
<View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
|
|
141
|
+
<SearchInput value={query} onChangeText={setQuery} placeholder="Search documents…" accessibilityLabel="Search documents" />
|
|
142
|
+
</View>
|
|
143
|
+
<FilterChip
|
|
144
|
+
label="Owner"
|
|
145
|
+
summary={owners.length > 0 ? <View style={{ flexDirection: "row", gap: 2 }}>{owners.slice(0, 4).map((o) => <Avatar key={o} name={o} size="sm" />)}</View> : undefined}
|
|
146
|
+
onClear={() => setOwners([])}
|
|
147
|
+
clearLabel="Clear owner filter"
|
|
148
|
+
>
|
|
149
|
+
<OptionList
|
|
150
|
+
multi
|
|
151
|
+
search={{ mode: "none" }}
|
|
152
|
+
options={OWNERS}
|
|
153
|
+
value={owners}
|
|
154
|
+
onValueChange={setOwners}
|
|
155
|
+
renderOptionContent={(o) => <MemberChip name={o.label ?? o.value} size="sm" />}
|
|
156
|
+
/>
|
|
157
|
+
</FilterChip>
|
|
158
|
+
</View>
|
|
159
|
+
|
|
160
|
+
{/* What is AT STAKE, not how many rows there are: the columns already
|
|
161
|
+
state their own sizes, so repeating the count here would be one
|
|
162
|
+
fact printed twice a few pixels apart. */}
|
|
163
|
+
<SummaryLine
|
|
164
|
+
items={[
|
|
165
|
+
{ label: "awaiting a decision", value: shown.filter((d) => d.stage === "intake" || d.stage === "review").length },
|
|
166
|
+
...(overdue > 0 ? [{ label: "past their date", value: overdue, tone: "warning" as const }] : []),
|
|
167
|
+
]}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
<Board>
|
|
171
|
+
{STAGES.map((s) => {
|
|
172
|
+
const inStage = shown.filter((d) => d.stage === s.key);
|
|
173
|
+
return (
|
|
174
|
+
<BoardColumn
|
|
175
|
+
key={s.key}
|
|
176
|
+
columnKey={s.key}
|
|
177
|
+
/* The heading is the component the value's data role owns — a
|
|
178
|
+
stage is a status, so it wears the same OptionBadge the
|
|
179
|
+
record surface uses for it. Two surfaces, one vocabulary. */
|
|
180
|
+
heading={<OptionBadge variant="dot" value={{ key: s.key, label: s.label, color: s.color }} />}
|
|
181
|
+
count={inStage.length}
|
|
182
|
+
emptyMessage="Nothing at this stage"
|
|
183
|
+
>
|
|
184
|
+
{inStage.map((d) => (
|
|
185
|
+
<BoardCard
|
|
186
|
+
key={d.id}
|
|
187
|
+
id={d.id}
|
|
188
|
+
title={d.title}
|
|
189
|
+
onPress={() => setOpen(d.id)}
|
|
190
|
+
/* Every stage but this one. Declaring them on the card is
|
|
191
|
+
what makes the keyboard menu and the drop target agree —
|
|
192
|
+
the drag resolves against this same list. */
|
|
193
|
+
moves={STAGES.filter((x) => x.key !== s.key).map((x) => ({ key: x.key, label: x.label }))}
|
|
194
|
+
onMove={(to) => move(d.id, to)}
|
|
195
|
+
>
|
|
196
|
+
<MemberChip name={d.owner} size="sm" />
|
|
197
|
+
{/* The date and its countdown in ONE run, in the kit's
|
|
198
|
+
deadline tone — a card is read at a glance, and a
|
|
199
|
+
separate badge for "late" would say twice what the
|
|
200
|
+
annotation already says once. */}
|
|
201
|
+
{d.due ? (
|
|
202
|
+
<Text size="xs" {...dueProps(d.due, d.stage === "issued")} />
|
|
203
|
+
) : (
|
|
204
|
+
<Text size="xs" color="muted">
|
|
205
|
+
No date
|
|
206
|
+
</Text>
|
|
207
|
+
)}
|
|
208
|
+
</BoardCard>
|
|
209
|
+
))}
|
|
210
|
+
</BoardColumn>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
</Board>
|
|
214
|
+
</View>
|
|
215
|
+
</ScrollView>
|
|
216
|
+
|
|
217
|
+
{/* A card is a door into the SAME record surface a register would open —
|
|
218
|
+
never a second detail view built for the board. */}
|
|
219
|
+
<Drawer open={record != null} onOpenChange={(v) => !v && setOpen(null)} title={record?.title}>
|
|
220
|
+
{record ? (
|
|
221
|
+
<DrawerScrollArea>
|
|
222
|
+
<DetailTable labelWidth={140}>
|
|
223
|
+
<DetailRow label="Reference">
|
|
224
|
+
<Text size="sm" tabular>{record.ref}</Text>
|
|
225
|
+
</DetailRow>
|
|
226
|
+
<DetailRow label="Stage">
|
|
227
|
+
<OptionBadge variant="dot" value={{ key: record.stage, label: STAGES.find((s) => s.key === record.stage)?.label ?? record.stage, color: STAGES.find((s) => s.key === record.stage)?.color ?? "zinc" }} />
|
|
228
|
+
</DetailRow>
|
|
229
|
+
<DetailRow label="Owner">
|
|
230
|
+
<MemberChip name={record.owner} />
|
|
231
|
+
</DetailRow>
|
|
232
|
+
<DetailRow label="Due">
|
|
233
|
+
{record.due ? <Text size="sm" {...dueProps(record.due, record.stage === "issued")} /> : <Text size="sm" color="muted">No date</Text>}
|
|
234
|
+
</DetailRow>
|
|
235
|
+
</DetailTable>
|
|
236
|
+
{/* The same act the card offers, in reach of the person who opened
|
|
237
|
+
it — a reader who drilled in should not have to go back out to
|
|
238
|
+
advance the thing they just read. */}
|
|
239
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, paddingTop: 16 }}>
|
|
240
|
+
{STAGES.filter((s) => s.key !== record.stage).map((s) => (
|
|
241
|
+
<Button
|
|
242
|
+
key={s.key}
|
|
243
|
+
color="secondary"
|
|
244
|
+
title={`Move to ${s.label}`}
|
|
245
|
+
onPress={() => {
|
|
246
|
+
move(record.id, s.key);
|
|
247
|
+
setOpen(null);
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
251
|
+
</View>
|
|
252
|
+
</DrawerScrollArea>
|
|
253
|
+
) : null}
|
|
254
|
+
</Drawer>
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
}
|