@lotics/ui 3.5.0 → 4.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.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,356 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, solid, type ColorName } from "@lotics/ui/colors";
5
+ import { ActionMenu } from "@lotics/ui/action_menu";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Button } from "@lotics/ui/button";
8
+ import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
9
+ import { Divider } from "@lotics/ui/divider";
10
+ import { Drawer } from "@lotics/ui/drawer";
11
+ import { Heatmap, type HeatmapCell } from "@lotics/ui/heatmap";
12
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
13
+ import { PressableRow } from "@lotics/ui/pressable_row";
14
+ import { StatusGrid, StatusLegend } from "@lotics/ui/status_grid";
15
+ import { Timeline } from "@lotics/ui/timeline";
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Template · Control tower — continuous monitoring of hundreds of live
19
+ // units (machines, gates, chargers, sensors). Three bands answer the three
20
+ // ops questions in order: KPI strip + StatusGrid = "is everything OK right
21
+ // now?" (scan for red; the legend drills a state), exceptions rail =
22
+ // "where do I look first?" (severity, then longest unresolved), Heatmap =
23
+ // "when/where does it cluster?" (pressing a cell explains the day). Cells
24
+ // and exception rows open the SAME sequenced unit drawer.
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ // One semantic reference per state — a palette family NAME. Every weight
28
+ // (grid cell, legend dot, the drawer Badge, the exception-rail dot) derives
29
+ // its shade from this; nothing carries a raw hex.
30
+ const STATES = [
31
+ { key: "running", label: "Running", color: "emerald" },
32
+ { key: "idle", label: "Idle", color: "zinc" },
33
+ { key: "maintenance", label: "Maintenance", color: "blue" },
34
+ { key: "warning", label: "Warning", color: "amber" },
35
+ { key: "down", label: "Down", color: "red" },
36
+ ] as const satisfies readonly { key: string; label: string; color: ColorName }[];
37
+
38
+ type StateKey = (typeof STATES)[number]["key"];
39
+
40
+ const SITES = [
41
+ { key: "eastport", label: "Eastport", count: 78 },
42
+ { key: "northgate", label: "Northgate", count: 64 },
43
+ { key: "riverside", label: "Riverside", count: 58 },
44
+ { key: "southbay", label: "Southbay", count: 48 },
45
+ ] as const;
46
+
47
+ type SiteKey = (typeof SITES)[number]["key"];
48
+
49
+ interface Unit {
50
+ key: string;
51
+ label: string;
52
+ state: StateKey;
53
+ site: SiteKey;
54
+ model: string;
55
+ serviceHours: number;
56
+ message?: string;
57
+ ageMin?: number;
58
+ }
59
+
60
+ // Deterministic mock fleet — integer hashing, stable across reloads.
61
+ function hash(i: number, salt: number): number {
62
+ let x = (i + 1) * 2654435761 + salt * 40503;
63
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
64
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
65
+ return (x >>> 16) % 1000;
66
+ }
67
+
68
+ function pick<T>(i: number, salt: number, weighted: [T, number][]): T {
69
+ const total = weighted.reduce((s, [, w]) => s + w, 0);
70
+ let roll = hash(i, salt) % total;
71
+ for (const [item, w] of weighted) {
72
+ if (roll < w) return item;
73
+ roll -= w;
74
+ }
75
+ return weighted[0][0];
76
+ }
77
+
78
+ const MODELS = ["HX-300", "HX-450", "LT-90", "RS-12"];
79
+ const DOWN_MSGS = ["Offline — no heartbeat", "Drive fault", "E-stop engaged"];
80
+ const WARN_MSGS = ["Temperature high", "Hydraulic pressure low", "Service overdue", "Battery low"];
81
+
82
+ const UNITS: Unit[] = (() => {
83
+ const units: Unit[] = [];
84
+ let i = 0;
85
+ for (const site of SITES) {
86
+ for (let n = 0; n < site.count; n++, i++) {
87
+ const state = pick<StateKey>(i, 1, [["running", 66], ["idle", 16], ["maintenance", 6], ["warning", 7], ["down", 3]]);
88
+ const unit: Unit = {
89
+ key: `EQ-${String(i + 1).padStart(3, "0")}`,
90
+ label: `EQ-${String(i + 1).padStart(3, "0")}`,
91
+ state,
92
+ site: site.key,
93
+ model: MODELS[hash(i, 2) % MODELS.length],
94
+ serviceHours: 40 + (hash(i, 3) % 460),
95
+ };
96
+ if (state === "down") {
97
+ unit.message = DOWN_MSGS[hash(i, 4) % DOWN_MSGS.length];
98
+ unit.ageMin = 5 + (hash(i, 5) % 170);
99
+ } else if (state === "warning") {
100
+ unit.message = WARN_MSGS[hash(i, 4) % WARN_MSGS.length];
101
+ unit.ageMin = 10 + (hash(i, 5) % 320);
102
+ }
103
+ units.push(unit);
104
+ }
105
+ }
106
+ return units;
107
+ })();
108
+
109
+ // Exceptions: severity first, then longest unresolved — the triage order.
110
+ const EXCEPTIONS = UNITS.filter((u) => u.state === "down" || u.state === "warning").sort(
111
+ (a, b) => (a.state === b.state ? (b.ageMin ?? 0) - (a.ageMin ?? 0) : a.state === "down" ? -1 : 1),
112
+ );
113
+
114
+ // Alarm history: sites × last 14 days, with a deliberate hot streak at
115
+ // Northgate so the heatmap has a story to drill.
116
+ const DAYS = ["30/05", "31/05", "01/06", "02/06", "03/06", "04/06", "05/06", "06/06", "07/06", "08/06", "09/06", "10/06", "11/06", "12/06"];
117
+ const ALARM_VALUES = SITES.map((site, r) =>
118
+ DAYS.map((_, c) => {
119
+ const base = hash(r * 14 + c, 6) % 6;
120
+ return site.key === "northgate" && c >= 9 ? base + 5 + (hash(c, 7) % 6) : base;
121
+ }),
122
+ );
123
+
124
+ const stateOf = (k: StateKey) => STATES.find((s) => s.key === k)!;
125
+ const siteOf = (k: SiteKey) => SITES.find((s) => s.key === k)!;
126
+
127
+ const formatAge = (min: number) => (min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `${min}m`);
128
+
129
+ function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
130
+ return (
131
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
132
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
133
+ {children}
134
+ </View>
135
+ );
136
+ }
137
+
138
+ /** Which list the drawer walks: the grid scan or the exceptions rail. */
139
+ type OpenRef = { list: "grid" | "exceptions"; idx: number };
140
+
141
+ export function TplTower() {
142
+ const [stateFilter, setStateFilter] = useState<string | null>(null);
143
+ const [open, setOpen] = useState<OpenRef | null>(null);
144
+ const [hotCell, setHotCell] = useState<HeatmapCell | null>(null);
145
+
146
+ // The grid walk follows the visible slice: all units in site order, or
147
+ // only the drilled state's units when the legend has a selection.
148
+ const gridWalk = useMemo(
149
+ () => (stateFilter ? UNITS.filter((u) => u.state === stateFilter) : UNITS),
150
+ [stateFilter],
151
+ );
152
+ const walk = open?.list === "exceptions" ? EXCEPTIONS : gridWalk;
153
+ const unit = open !== null ? walk[open.idx] : null;
154
+
155
+ const running = UNITS.filter((u) => u.state === "running").length;
156
+ const maintenance = UNITS.filter((u) => u.state === "maintenance").length;
157
+ const availability = Math.round(((running + UNITS.filter((u) => u.state === "idle").length) / UNITS.length) * 100);
158
+ const oldest = EXCEPTIONS.reduce((m, u) => Math.max(m, u.ageMin ?? 0), 0);
159
+
160
+ const hotValue = hotCell
161
+ ? ALARM_VALUES[SITES.findIndex((s) => s.key === hotCell.row)]?.[DAYS.indexOf(hotCell.col)] ?? 0
162
+ : 0;
163
+
164
+ return (
165
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
166
+ <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
167
+ {/* header band */}
168
+ <View style={{ gap: 2 }}>
169
+ <Text size="xl" weight="semibold">Control tower</Text>
170
+ <Text size="sm" color="muted">Every unit's live state — scan for red, work the exceptions, read the pattern</Text>
171
+ </View>
172
+
173
+ {/* fleet health — cross-cutting numbers, not a mirror of the legend */}
174
+ <KPIStrip
175
+ items={[
176
+ { label: "Running now", value: running, format: "number", caption: `of ${UNITS.length} units` },
177
+ { label: "Availability", value: availability, format: "percentage", info: "Share of the fleet ready to work — running or idle. Down and maintenance units are excluded." },
178
+ { label: "Active alarms", value: EXCEPTIONS.length, format: "number", tone: "danger", caption: `oldest ${formatAge(oldest)}`, info: "Down and warning units, triaged in the exceptions rail — severity first, then longest unresolved." },
179
+ { label: "In maintenance", value: maintenance, format: "number", caption: "planned work" },
180
+ ]}
181
+ />
182
+
183
+ <View style={{ flexDirection: "row", gap: 16, alignItems: "stretch", flexWrap: "wrap" }}>
184
+ {/* live-state scan */}
185
+ <Card style={{ padding: 0, flexGrow: 2, flexBasis: 560 }}>
186
+ <CardHeader>
187
+ <CardHeaderTitle info="One cell per unit, colored by live state. Press a legend state to isolate it across all sites; press a cell to open the unit.">
188
+ Live unit status
189
+ </CardHeaderTitle>
190
+ </CardHeader>
191
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 16 }}>
192
+ <StatusLegend
193
+ items={UNITS}
194
+ states={STATES.map((s) => ({ key: s.key, label: s.label, color: s.color }))}
195
+ selectedKey={stateFilter}
196
+ onSelect={(k) => { setStateFilter(k); setOpen(null); }}
197
+ />
198
+ {SITES.map((site) => {
199
+ const siteUnits = UNITS.filter((u) => u.site === site.key);
200
+ return (
201
+ <View key={site.key} style={{ gap: 8 }}>
202
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
203
+ <Text size="xs" color="muted" transform="uppercase">{site.label}</Text>
204
+ <Text size="xs" color="muted" tabular>{siteUnits.length}</Text>
205
+ </View>
206
+ <StatusGrid
207
+ items={siteUnits}
208
+ states={STATES.map((s) => ({ key: s.key, label: s.label, color: s.color }))}
209
+ selectedState={stateFilter}
210
+ activeKey={unit?.key ?? null}
211
+ onPressItem={(key) => {
212
+ const idx = gridWalk.findIndex((u) => u.key === key);
213
+ if (idx >= 0) setOpen({ list: "grid", idx });
214
+ }}
215
+ />
216
+ </View>
217
+ );
218
+ })}
219
+ </View>
220
+ </Card>
221
+
222
+ {/* exception triage */}
223
+ <Card style={{ padding: 0, flexGrow: 1, flexBasis: 340 }}>
224
+ <CardHeader>
225
+ <CardHeaderTitle info="Every down or warning unit — severity first, then longest unresolved. Press a row to open the unit; ⋯ to acknowledge or assign.">
226
+ Active exceptions
227
+ </CardHeaderTitle>
228
+ </CardHeader>
229
+ <View style={{ flex: 1, paddingVertical: 4 }}>
230
+ {EXCEPTIONS.slice(0, 12).map((u, i) => (
231
+ <View key={u.key}>
232
+ {i > 0 ? <Divider /> : null}
233
+ <PressableRow
234
+ onPress={() => setOpen({ list: "exceptions", idx: i })}
235
+ selected={open?.list === "exceptions" && unit?.key === u.key}
236
+ style={{ gap: 6 }}
237
+ >
238
+ <Pressable
239
+ accessibilityRole="button"
240
+ accessibilityLabel={`Open ${u.key}: ${u.message}`}
241
+ onPress={() => setOpen({ list: "exceptions", idx: i })}
242
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 10, minHeight: 44 }}
243
+ >
244
+ <View style={{ width: 8, height: 8, borderRadius: 999, backgroundColor: solid(stateOf(u.state).color) }} />
245
+ <Text size="sm" weight="medium" tabular style={{ width: 58 }}>{u.key}</Text>
246
+ <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{u.message}</Text>
247
+ <Text size="xs" tabular color={(u.ageMin ?? 0) > 60 ? "danger" : "muted"}>
248
+ {formatAge(u.ageMin ?? 0)}
249
+ </Text>
250
+ </Pressable>
251
+ <ActionMenu
252
+ accessibilityLabel={`Actions for ${u.key}`}
253
+ items={[
254
+ { key: "ack", label: "Acknowledge", icon: "check", onPress: () => {} },
255
+ { key: "assign", label: "Assign technician", icon: "user-check", onPress: () => {} },
256
+ { key: "mute", label: "Mute alarm", icon: "ban", danger: true, onPress: () => {} },
257
+ ]}
258
+ />
259
+ </PressableRow>
260
+ </View>
261
+ ))}
262
+ </View>
263
+ <CardFooter>
264
+ <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
265
+ {`12 of ${EXCEPTIONS.length} · the drawer's › walks the full queue`}
266
+ </Text>
267
+ <Button title="View all" color="muted" onPress={() => {}} />
268
+ </CardFooter>
269
+ </Card>
270
+ </View>
271
+
272
+ {/* temporal pattern */}
273
+ <Card style={{ padding: 0 }}>
274
+ <CardHeader>
275
+ <CardHeaderTitle info="Alarm count by site and day. Intensity scales to the worst cell — press one to see what drove that day.">
276
+ Alarm pattern · last 14 days
277
+ </CardHeaderTitle>
278
+ </CardHeader>
279
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 12 }}>
280
+ <Heatmap
281
+ rows={SITES.map((s) => ({ key: s.key, label: s.label }))}
282
+ cols={DAYS.map((d) => ({ key: d, label: d }))}
283
+ values={ALARM_VALUES}
284
+ color={solid("red")}
285
+ selected={hotCell}
286
+ onSelectCell={setHotCell}
287
+ />
288
+ <Text size="sm" color="muted" tabular>
289
+ {hotCell
290
+ ? hotValue === 0
291
+ ? `${siteOf(hotCell.row as SiteKey).label} · ${hotCell.col} — no alarms`
292
+ : `${siteOf(hotCell.row as SiteKey).label} · ${hotCell.col} — ${hotValue} alarms · most frequent: ${WARN_MSGS[hash(DAYS.indexOf(hotCell.col), 8) % WARN_MSGS.length]}`
293
+ : "Press a cell to see what drove that day"}
294
+ </Text>
295
+ </View>
296
+ </Card>
297
+ </View>
298
+
299
+ {unit !== null && open !== null ? (
300
+ <Drawer
301
+ open
302
+ onOpenChange={(o) => !o && setOpen(null)}
303
+ title={unit.key}
304
+ width={440}
305
+ onPrev={open.idx > 0 ? () => setOpen({ ...open, idx: open.idx - 1 }) : undefined}
306
+ onNext={open.idx < walk.length - 1 ? () => setOpen({ ...open, idx: open.idx + 1 }) : undefined}
307
+ position={`${open.idx + 1}/${walk.length}`}
308
+ >
309
+ <View key={unit.key} style={{ flex: 1, padding: 24, gap: 16 }}>
310
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
311
+ <Badge label={stateOf(unit.state).label} color={stateOf(unit.state).color} />
312
+ <Badge label={siteOf(unit.site).label} />
313
+ </View>
314
+ {unit.message ? (
315
+ <Text size="sm" color="danger">{`${unit.message} · ${formatAge(unit.ageMin ?? 0)} ago`}</Text>
316
+ ) : null}
317
+ <View style={{ gap: 6 }}>
318
+ <KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
319
+ <Divider />
320
+ <KVRow label="Model"><Text size="sm" tabular>{unit.model}</Text></KVRow>
321
+ <Divider />
322
+ <KVRow label="Hours since service"><Text size="sm" tabular>{unit.serviceHours.toLocaleString("en-US")}</Text></KVRow>
323
+ </View>
324
+ <View style={{ gap: 8, paddingTop: 4 }}>
325
+ <Text size="sm" weight="semibold">Recent events</Text>
326
+ <Timeline
327
+ items={[
328
+ ...(unit.message
329
+ ? [{ id: "alarm", icon: "circle-alert" as const, iconColor: solid(stateOf(unit.state).color), label: unit.message, right: <Text size="xs" color="muted" tabular>{formatAge(unit.ageMin ?? 0)} ago</Text> }]
330
+ : []),
331
+ { id: "cycle", icon: "activity" as const, iconColor: solid("blue"), label: `Completed ${120 + hash(unit.serviceHours, 9) % 300} cycles today`, right: <Text size="xs" color="muted" tabular>today</Text> },
332
+ { id: "service", icon: "settings" as const, iconColor: colors.zinc[500], label: "Scheduled service completed", right: <Text size="xs" color="muted" tabular>{`${1 + hash(unit.serviceHours, 10) % 20}d ago`}</Text> },
333
+ ]}
334
+ />
335
+ </View>
336
+ </View>
337
+ <View
338
+ style={{
339
+ borderTopWidth: 1,
340
+ borderTopColor: colors.border,
341
+ paddingHorizontal: 20,
342
+ paddingVertical: 14,
343
+ flexDirection: "row",
344
+ alignItems: "center",
345
+ justifyContent: "flex-end",
346
+ gap: 12,
347
+ }}
348
+ >
349
+ <Button title="Create work order" color="secondary" onPress={() => {}} />
350
+ <Button title="Open unit record" color="primary" onPress={() => {}} />
351
+ </View>
352
+ </Drawer>
353
+ ) : null}
354
+ </ScrollView>
355
+ );
356
+ }
@@ -0,0 +1,223 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Card, CardBody } from "@lotics/ui/card";
7
+ import { Stepper } from "@lotics/ui/stepper";
8
+ import { FormField } from "@lotics/ui/form_field";
9
+ import { TextInputField } from "@lotics/ui/text_input_field";
10
+ import { Picker } from "@lotics/ui/picker";
11
+ import type { PickerOption } from "@lotics/ui/picker";
12
+ import { NumberInput } from "@lotics/ui/number_input";
13
+ import { Switch } from "@lotics/ui/switch";
14
+ import { DetailRow } from "@lotics/ui/detail_row";
15
+ import { Divider } from "@lotics/ui/divider";
16
+ import { Callout, CalloutText } from "@lotics/ui/callout";
17
+ import { formatMoney } from "@lotics/ui/format_money";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Template · Application wizard — a long form broken into ordered STEPS with a
21
+ // `Stepper` header, one fieldset per step, and a REVIEW step that reads back
22
+ // every value with an Edit jump to its section. Back / Next gate on the current
23
+ // step's required fields; the last step submits. For a form too long to scan as
24
+ // one page (onboarding, applications, KYC). Two-column grid throughout.
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ const STEPS = ["Company", "Contact", "Terms", "Review"];
28
+
29
+ const INDUSTRY: PickerOption[] = [
30
+ { value: "manufacturing", label: "Manufacturing" },
31
+ { value: "trading", label: "Trading" },
32
+ { value: "construction", label: "Construction" },
33
+ { value: "transport", label: "Transportation" },
34
+ { value: "retail", label: "Retail" },
35
+ ];
36
+
37
+ const SIZE: PickerOption[] = [
38
+ { value: "1-10", label: "1–10" },
39
+ { value: "11-50", label: "11–50" },
40
+ { value: "51-200", label: "51–200" },
41
+ { value: "200+", label: "200+" },
42
+ ];
43
+
44
+ const TERMS: PickerOption[] = [
45
+ { value: "receipt", label: "Due on receipt" },
46
+ { value: "15", label: "Net 15" },
47
+ { value: "30", label: "Net 30" },
48
+ { value: "45", label: "Net 45" },
49
+ ];
50
+
51
+ const labelOf = (opts: PickerOption[], v: string) => opts.find((o) => o.value === v)?.label ?? "—";
52
+
53
+ const half = { flexGrow: 1, flexBasis: 240 } as const;
54
+ const full = { flexGrow: 1, flexBasis: "100%" } as const;
55
+
56
+ export function TplWizard() {
57
+ const [step, setStep] = useState(0);
58
+
59
+ // Company
60
+ const [companyName, setCompanyName] = useState("Northwind Packaging Co., Ltd.");
61
+ const [taxId, setTaxId] = useState("0312456789");
62
+ const [industry, setIndustry] = useState("manufacturing");
63
+ const [regCity, setRegCity] = useState("Gothenburg");
64
+ const [size, setSize] = useState("51-200");
65
+
66
+ // Contact
67
+ const [contactName, setContactName] = useState("Mara Lindqvist");
68
+ const [role, setRole] = useState("Procurement lead");
69
+ const [phone, setPhone] = useState("0903 558 214");
70
+ const [email, setEmail] = useState("mara@northwind.co");
71
+
72
+ // Terms
73
+ const [terms, setTerms] = useState("30");
74
+ const [creditLimit, setCreditLimit] = useState<number | null>(50000000);
75
+ const [vatInvoice, setVatInvoice] = useState(true);
76
+ const [notes, setNotes] = useState("");
77
+
78
+ const stepValid = [
79
+ companyName.trim() !== "" && taxId.trim() !== "",
80
+ contactName.trim() !== "" && email.trim() !== "",
81
+ terms !== "",
82
+ true,
83
+ ];
84
+ const isLast = step === STEPS.length - 1;
85
+
86
+ const goEdit = (s: number) => setStep(s);
87
+
88
+ return (
89
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
90
+ <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
91
+ <View style={{ gap: 2 }}>
92
+ <Text size="xl" weight="semibold">New partner application</Text>
93
+ <Text size="sm" color="muted">Four short steps — we save as you go; review everything before submitting</Text>
94
+ </View>
95
+
96
+ <Card style={{ padding: 0 }}>
97
+ <CardBody>
98
+ <Stepper steps={STEPS} current={step} />
99
+ </CardBody>
100
+ <Divider />
101
+
102
+ {/* step 0 · Company */}
103
+ {step === 0 ? (
104
+ <CardBody>
105
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
106
+ <FormField label="Legal name" style={full}>
107
+ <TextInputField value={companyName} onChangeText={setCompanyName} placeholder="Name on the business registration" autoFocus />
108
+ </FormField>
109
+ <FormField label="Tax ID" style={half}>
110
+ <TextInputField value={taxId} onChangeText={setTaxId} placeholder="10 or 13 digits" inputMode="numeric" />
111
+ </FormField>
112
+ <FormField label="Industry" style={half}>
113
+ <Picker options={INDUSTRY} value={industry} onValueChange={setIndustry} placeholder="Select industry" accessibilityLabel="Industry" />
114
+ </FormField>
115
+ <FormField label="Registered city" style={half}>
116
+ <TextInputField value={regCity} onChangeText={setRegCity} placeholder="City" />
117
+ </FormField>
118
+ <FormField label="Company size" style={half}>
119
+ <Picker options={SIZE} value={size} onValueChange={setSize} placeholder="Headcount" accessibilityLabel="Company size" />
120
+ </FormField>
121
+ </View>
122
+ </CardBody>
123
+ ) : null}
124
+
125
+ {/* step 1 · Contact */}
126
+ {step === 1 ? (
127
+ <CardBody>
128
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
129
+ <FormField label="Contact person" style={half}>
130
+ <TextInputField value={contactName} onChangeText={setContactName} placeholder="Full name" autoFocus />
131
+ </FormField>
132
+ <FormField label="Role" style={half}>
133
+ <TextInputField value={role} onChangeText={setRole} placeholder="Job title" />
134
+ </FormField>
135
+ <FormField label="Phone" style={half}>
136
+ <TextInputField value={phone} onChangeText={setPhone} placeholder="Mobile number" inputMode="tel" />
137
+ </FormField>
138
+ <FormField label="Email" style={half}>
139
+ <TextInputField value={email} onChangeText={setEmail} placeholder="name@company.com" inputMode="email" autoCapitalize="none" />
140
+ </FormField>
141
+ </View>
142
+ </CardBody>
143
+ ) : null}
144
+
145
+ {/* step 2 · Terms */}
146
+ {step === 2 ? (
147
+ <CardBody>
148
+ <View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
149
+ <FormField label="Payment terms" style={half}>
150
+ <Picker options={TERMS} value={terms} onValueChange={setTerms} placeholder="Select terms" accessibilityLabel="Payment terms" />
151
+ </FormField>
152
+ <FormField label="Requested credit limit" style={half}>
153
+ <NumberInput value={creditLimit} onValueChange={setCreditLimit} min={0} accessibilityLabel="Requested credit limit" />
154
+ </FormField>
155
+ <FormField label="VAT invoice" style={full}>
156
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 40 }}>
157
+ <Switch accessibilityLabel="VAT invoice" value={vatInvoice} onChange={setVatInvoice} />
158
+ <Text size="sm" color="muted">{vatInvoice ? "Issue an invoice for every order" : "No invoices issued"}</Text>
159
+ </View>
160
+ </FormField>
161
+ <FormField label="Notes" optional optionalLabel="Optional" style={full}>
162
+ <TextInputField value={notes} onChangeText={setNotes} placeholder="Anything the credit team should know" numberOfLines={3} />
163
+ </FormField>
164
+ </View>
165
+ </CardBody>
166
+ ) : null}
167
+
168
+ {/* step 3 · Review */}
169
+ {step === 3 ? (
170
+ <CardBody>
171
+ <Callout tone="info"><CalloutText>Check every detail — once submitted, changes need a credit-team request.</CalloutText></Callout>
172
+ <ReviewGroup title="Company" onEdit={() => goEdit(0)}>
173
+ <DetailRow label="Legal name" labelWidth={150}><Text size="sm">{companyName}</Text></DetailRow>
174
+ <DetailRow label="Tax ID" labelWidth={150}><Text size="sm" tabular>{taxId}</Text></DetailRow>
175
+ <DetailRow label="Industry" labelWidth={150}><Text size="sm">{labelOf(INDUSTRY, industry)}</Text></DetailRow>
176
+ <DetailRow label="Registered city" labelWidth={150}><Text size="sm">{regCity}</Text></DetailRow>
177
+ <DetailRow label="Company size" labelWidth={150}><Text size="sm">{labelOf(SIZE, size)}</Text></DetailRow>
178
+ </ReviewGroup>
179
+ <Divider />
180
+ <ReviewGroup title="Contact" onEdit={() => goEdit(1)}>
181
+ <DetailRow label="Contact person" labelWidth={150}><Text size="sm">{contactName}</Text></DetailRow>
182
+ <DetailRow label="Role" labelWidth={150}><Text size="sm">{role}</Text></DetailRow>
183
+ <DetailRow label="Phone" labelWidth={150}><Text size="sm" tabular>{phone}</Text></DetailRow>
184
+ <DetailRow label="Email" labelWidth={150}><Text size="sm">{email}</Text></DetailRow>
185
+ </ReviewGroup>
186
+ <Divider />
187
+ <ReviewGroup title="Terms" onEdit={() => goEdit(2)}>
188
+ <DetailRow label="Payment terms" labelWidth={150}><Text size="sm">{labelOf(TERMS, terms)}</Text></DetailRow>
189
+ <DetailRow label="Credit limit" labelWidth={150}><Text size="sm" tabular>{creditLimit == null ? "—" : formatMoney(creditLimit)}</Text></DetailRow>
190
+ <DetailRow label="VAT invoice" labelWidth={150}><Text size="sm">{vatInvoice ? "Yes" : "No"}</Text></DetailRow>
191
+ <DetailRow label="Notes" labelWidth={150}><Text size="sm" color={notes ? "default" : "muted"}>{notes || "—"}</Text></DetailRow>
192
+ </ReviewGroup>
193
+ </CardBody>
194
+ ) : null}
195
+
196
+ <Divider />
197
+ <View style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 20, paddingVertical: 16, gap: 8 }}>
198
+ {step > 0 ? <Button title="Back" color="muted" icon="chevron-left" onPress={() => setStep((s) => s - 1)} /> : null}
199
+ <View style={{ flex: 1 }} />
200
+ <Text size="xs" color="muted">{`Step ${step + 1} of ${STEPS.length}`}</Text>
201
+ {isLast ? (
202
+ <Button title="Submit application" color="primary" onPress={() => {}} />
203
+ ) : (
204
+ <Button title="Next" color="primary" disabled={!stepValid[step]} onPress={() => setStep((s) => s + 1)} />
205
+ )}
206
+ </View>
207
+ </Card>
208
+ </View>
209
+ </ScrollView>
210
+ );
211
+ }
212
+
213
+ function ReviewGroup({ title, onEdit, children }: { title: string; onEdit: () => void; children: React.ReactNode }) {
214
+ return (
215
+ <View style={{ gap: 2 }}>
216
+ <View style={{ flexDirection: "row", alignItems: "center", paddingBottom: 4 }}>
217
+ <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>{title}</Text>
218
+ <Button title="Edit" color="muted" icon="pencil" onPress={onEdit} />
219
+ </View>
220
+ {children}
221
+ </View>
222
+ );
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.5.0",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -64,6 +64,7 @@
64
64
  "./button": "./src/button.tsx",
65
65
  "./checkbox": "./src/checkbox.tsx",
66
66
  "./combobox": "./src/combobox.tsx",
67
+ "./tag_input": "./src/tag_input.tsx",
67
68
  "./composer": "./src/composer.tsx",
68
69
  "./portal": "./src/portal.tsx",
69
70
  "./popover_nav": "./src/popover_nav.tsx",
@@ -82,6 +83,12 @@
82
83
  "./card_select_item": "./src/card_select_item.tsx",
83
84
  "./badge": "./src/badge.tsx",
84
85
  "./callout": "./src/callout.tsx",
86
+ "./inline_edit": "./src/inline_edit.tsx",
87
+ "./inline_text_input": "./src/inline_text_input.tsx",
88
+ "./inline_number_input": "./src/inline_number_input.tsx",
89
+ "./inline_select": "./src/inline_select.tsx",
90
+ "./inline_date_picker": "./src/inline_date_picker.tsx",
91
+ "./inline_time_picker": "./src/inline_time_picker.tsx",
85
92
  "./divider": "./src/divider.tsx",
86
93
  "./spacer": "./src/spacer.tsx",
87
94
  "./index.css": "./src/index.css",
@@ -172,7 +179,9 @@
172
179
  "./chip_group": "./src/chip_group.tsx"
173
180
  },
174
181
  "files": [
175
- "src"
182
+ "src",
183
+ "examples",
184
+ "AGENTS.md"
176
185
  ],
177
186
  "publishConfig": {
178
187
  "access": "public"
package/src/bar_chart.tsx CHANGED
@@ -60,6 +60,11 @@ function useAxisTicks(maxValue: number) {
60
60
  }, [maxValue]);
61
61
  }
62
62
 
63
+ /**
64
+ * The canonical SVG bar chart over `data: { label, value, color? }[]` — `orientation`
65
+ * vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels. (No recharts.) For
66
+ * one inline trend use `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
67
+ */
63
68
  export function BarChart(props: BarChartProps) {
64
69
  const {
65
70
  data,