@lotics/ui 7.8.0 → 7.10.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 +92 -43
- package/examples/tpl_allocate.tsx +6 -6
- package/examples/tpl_assistant.tsx +1 -1
- package/examples/tpl_attendance.tsx +1 -1
- package/examples/tpl_billing.tsx +2 -2
- package/examples/tpl_briefing.tsx +1 -1
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_compare.tsx +1 -1
- package/examples/tpl_crosscheck.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_detail.tsx +1 -1
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_directory.tsx +5 -8
- package/examples/tpl_draft.tsx +2 -2
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_intake.tsx +1 -1
- package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
- package/examples/tpl_lookup.tsx +2 -2
- package/examples/tpl_match.tsx +2 -2
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pick.tsx +64 -68
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_quick.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record_plain.tsx +121 -16
- package/examples/tpl_report.tsx +1 -1
- package/examples/tpl_rollup.tsx +1 -1
- package/examples/tpl_settings.tsx +1 -1
- package/examples/tpl_shifts.tsx +1 -1
- package/examples/tpl_stock.tsx +1 -1
- package/examples/tpl_task_board.tsx +2 -2
- package/examples/tpl_tasks.tsx +1 -1
- package/examples/tpl_timeline.tsx +1 -1
- package/examples/tpl_tower.tsx +1 -1
- package/examples/tpl_triage.tsx +1 -1
- package/examples/tpl_wizard.tsx +1 -1
- package/package.json +5 -1
- package/src/card.tsx +6 -13
- package/src/danger_zone.tsx +58 -0
- package/src/data_grid.tsx +1 -1
- package/src/detail_row.tsx +8 -1
- package/src/inline_edit.tsx +14 -4
- package/src/inline_select.tsx +9 -8
- package/src/inline_static.tsx +63 -0
- package/src/kpi_strip.tsx +5 -5
- package/src/pressable_row.tsx +22 -10
- package/src/sort_header.tsx +9 -4
- package/src/summary_line.tsx +76 -0
- package/src/table.tsx +27 -11
- package/src/use_selection.test.ts +66 -0
- package/src/use_selection.ts +75 -0
- package/examples/app_orders.tsx +0 -405
- package/examples/tpl_approvals.tsx +0 -375
- package/examples/tpl_batch.tsx +0 -234
- package/examples/tpl_callsheet.tsx +0 -481
- package/examples/tpl_convert.tsx +0 -478
- package/examples/tpl_crm_desk.tsx +0 -541
- package/examples/tpl_dispatch.tsx +0 -289
- package/examples/tpl_inventory.tsx +0 -299
- package/examples/tpl_pipeline.tsx +0 -400
- package/examples/tpl_reconcile.tsx +0 -275
- package/examples/tpl_record.tsx +0 -302
- package/examples/tpl_run.tsx +0 -235
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
+
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
5
|
import { SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
6
6
|
import { Divider } from "@lotics/ui/divider";
|
|
7
7
|
import { DetailRow } from "@lotics/ui/detail_row";
|
|
8
8
|
import { Badge } from "@lotics/ui/badge";
|
|
9
|
+
import { Button } from "@lotics/ui/button";
|
|
10
|
+
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
11
|
+
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
9
12
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
10
13
|
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
11
14
|
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
@@ -13,10 +16,17 @@ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
|
13
16
|
import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
|
|
14
17
|
|
|
15
18
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
// Template · Inline record (plain) —
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Template · Inline record (plain) — a full record DRAWER, card-less. It reads
|
|
20
|
+
// top → bottom like a real drawer: a HEADER surfacing the record's identity +
|
|
21
|
+
// status + key facts, then the in-place inline fields, then a DANGER ZONE for
|
|
22
|
+
// the destructive action. Sections are set off by a `SectionHeading` + hairline
|
|
23
|
+
// and generous rhythm, not card chrome — the lighter look for a single
|
|
24
|
+
// full-screen record where banded cards would be visual noise.
|
|
25
|
+
//
|
|
26
|
+
// It's also the reference for the THREE-PART ROW — label · inline editor ·
|
|
27
|
+
// optional `trailing` (an action / badge / unit) — and for read-only alignment:
|
|
28
|
+
// an `InlineStatic` value sits PIXEL-FOR-PIXEL in the same column as the editors
|
|
29
|
+
// (see the "Row anatomy" section).
|
|
20
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
31
|
|
|
22
32
|
const PLAN = [
|
|
@@ -37,6 +47,20 @@ const HEALTH_DOT: Record<string, string> = {
|
|
|
37
47
|
churning: colors.red[500],
|
|
38
48
|
};
|
|
39
49
|
|
|
50
|
+
// The same health states as a Badge color family (the header's status chip).
|
|
51
|
+
const HEALTH_BADGE: Record<string, ColorName> = {
|
|
52
|
+
healthy: "emerald",
|
|
53
|
+
at_risk: "amber",
|
|
54
|
+
churning: "red",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const STAGE = [
|
|
58
|
+
{ value: "lead", label: "Lead" },
|
|
59
|
+
{ value: "qualified", label: "Qualified" },
|
|
60
|
+
{ value: "proposal", label: "Proposal" },
|
|
61
|
+
{ value: "won", label: "Won" },
|
|
62
|
+
];
|
|
63
|
+
|
|
40
64
|
function persist<T>(set: (v: T) => void) {
|
|
41
65
|
return (v: T) =>
|
|
42
66
|
new Promise<void>((resolve) => {
|
|
@@ -47,9 +71,9 @@ function persist<T>(set: (v: T) => void) {
|
|
|
47
71
|
});
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
74
|
+
function Field({ label, children, trailing }: { label: string; children: React.ReactNode; trailing?: React.ReactNode }) {
|
|
51
75
|
return (
|
|
52
|
-
<DetailRow label={label} labelWidth={130} minHeight={40}>
|
|
76
|
+
<DetailRow label={label} labelWidth={130} minHeight={40} trailing={trailing}>
|
|
53
77
|
<View style={{ flex: 1 }}>{children}</View>
|
|
54
78
|
</DetailRow>
|
|
55
79
|
);
|
|
@@ -67,6 +91,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|
|
67
91
|
);
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
// A compact label-over-value pair for the header's key-facts strip.
|
|
95
|
+
function HeaderStat({ label, value }: { label: string; value: string }) {
|
|
96
|
+
return (
|
|
97
|
+
<View style={{ gap: 2 }}>
|
|
98
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
99
|
+
{label}
|
|
100
|
+
</Text>
|
|
101
|
+
<Text size="sm" weight="medium" tabular>
|
|
102
|
+
{value}
|
|
103
|
+
</Text>
|
|
104
|
+
</View>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
export function TplRecordPlain() {
|
|
71
109
|
const [name, setName] = useState("Northwind Traders");
|
|
72
110
|
const [contact, setContact] = useState("Mara Lindqvist");
|
|
@@ -79,21 +117,42 @@ export function TplRecordPlain() {
|
|
|
79
117
|
const [renews, setRenews] = useState("2026-05-22");
|
|
80
118
|
const [review, setReview] = useState("2026-04-10T14:30");
|
|
81
119
|
const [opens, setOpens] = useState("09:00");
|
|
120
|
+
// "Row anatomy" fields.
|
|
121
|
+
const [taxId, setTaxId] = useState("SE556677-8899");
|
|
122
|
+
const [stage, setStage] = useState("proposal");
|
|
123
|
+
const [seats, setSeats] = useState<number | null>(25);
|
|
82
124
|
|
|
83
125
|
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
126
|
+
const planLabel = PLAN.find((p) => p.value === plan)?.label ?? "—";
|
|
127
|
+
const healthLabel = HEALTH.find((h) => h.value === health)?.label ?? "";
|
|
128
|
+
// Read-only, system-owned values that still align with the editors above.
|
|
129
|
+
const accountId = "ACC-4821";
|
|
130
|
+
const lifetimeValue = 128400;
|
|
84
131
|
|
|
85
132
|
return (
|
|
86
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
133
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
87
134
|
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
{/* Record header — identity + status + the facts you'd want at a glance,
|
|
136
|
+
composed in-place (not worth a primitive until a 2nd screen needs it). */}
|
|
137
|
+
<View style={{ gap: 12 }}>
|
|
138
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
139
|
+
<Text size="xl" weight="semibold">
|
|
140
|
+
{name}
|
|
141
|
+
</Text>
|
|
142
|
+
<Badge label={healthLabel} color={HEALTH_BADGE[health]} />
|
|
143
|
+
<View style={{ flex: 1 }} />
|
|
144
|
+
<Text size="xs" color="muted">
|
|
145
|
+
Click any value to edit
|
|
146
|
+
</Text>
|
|
147
|
+
</View>
|
|
148
|
+
<Text size="sm" color="muted">
|
|
149
|
+
{contact} · {email}
|
|
96
150
|
</Text>
|
|
151
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 28 }}>
|
|
152
|
+
<HeaderStat label="Plan" value={planLabel} />
|
|
153
|
+
<HeaderStat label="Annual value" value={money(value) || "—"} />
|
|
154
|
+
<HeaderStat label="City" value={city || "—"} />
|
|
155
|
+
</View>
|
|
97
156
|
</View>
|
|
98
157
|
|
|
99
158
|
<Section title="Contact">
|
|
@@ -148,6 +207,52 @@ export function TplRecordPlain() {
|
|
|
148
207
|
<InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
|
|
149
208
|
</Field>
|
|
150
209
|
</Section>
|
|
210
|
+
|
|
211
|
+
{/* Row anatomy — the three-part row (label · editor · trailing) and
|
|
212
|
+
read-only alignment, ALL in one 130px-label column so the editors and
|
|
213
|
+
the InlineStatic values line up to the pixel. Copy these shapes. */}
|
|
214
|
+
<Section title="Row anatomy">
|
|
215
|
+
{/* editor + a trailing ACTION */}
|
|
216
|
+
<Field
|
|
217
|
+
label="Tax ID"
|
|
218
|
+
trailing={<Button title="Verify" color="secondary" onPress={() => {}} />}
|
|
219
|
+
>
|
|
220
|
+
<InlineTextInput value={taxId} onSave={persist(setTaxId)} placeholder="Add tax ID…" accessibilityLabel="Tax ID" />
|
|
221
|
+
</Field>
|
|
222
|
+
{/* editor + a trailing STATUS dot */}
|
|
223
|
+
<Field
|
|
224
|
+
label="Pipeline stage"
|
|
225
|
+
trailing={<Badge variant="dot" color="emerald" label="Active" />}
|
|
226
|
+
>
|
|
227
|
+
<InlineSelect value={stage} onSave={persist(setStage)} options={STAGE} accessibilityLabel="Pipeline stage" />
|
|
228
|
+
</Field>
|
|
229
|
+
{/* editor + a trailing UNIT label */}
|
|
230
|
+
<Field
|
|
231
|
+
label="Licensed seats"
|
|
232
|
+
trailing={
|
|
233
|
+
<Text size="sm" color="muted">
|
|
234
|
+
seats
|
|
235
|
+
</Text>
|
|
236
|
+
}
|
|
237
|
+
>
|
|
238
|
+
<InlineNumberInput value={seats} onSave={persist(setSeats)} min={0} accessibilityLabel="Licensed seats" />
|
|
239
|
+
</Field>
|
|
240
|
+
{/* READ-ONLY value — aligns with the editors above, no input chrome */}
|
|
241
|
+
<Field label="Account ID">
|
|
242
|
+
<InlineStatic value={accountId} tabular />
|
|
243
|
+
</Field>
|
|
244
|
+
{/* READ-ONLY value + a trailing MARK */}
|
|
245
|
+
<Field label="Lifetime value" trailing={<Badge label="Computed" color="zinc" />}>
|
|
246
|
+
<InlineStatic value={money(lifetimeValue)} tabular />
|
|
247
|
+
</Field>
|
|
248
|
+
</Section>
|
|
249
|
+
|
|
250
|
+
<DangerZone
|
|
251
|
+
title="Delete account"
|
|
252
|
+
description="Permanently remove this account and all of its contacts, deals, and history. This can't be undone."
|
|
253
|
+
>
|
|
254
|
+
<Button title="Delete account" color="danger" onPress={() => {}} />
|
|
255
|
+
</DangerZone>
|
|
151
256
|
</View>
|
|
152
257
|
</ScrollView>
|
|
153
258
|
);
|
package/examples/tpl_report.tsx
CHANGED
|
@@ -217,7 +217,7 @@ export function TplReport() {
|
|
|
217
217
|
];
|
|
218
218
|
|
|
219
219
|
return (
|
|
220
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
220
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
221
221
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
222
222
|
{/* header band — title + the persistent period + the one primary action */}
|
|
223
223
|
<View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap", zIndex: 20 }}>
|
package/examples/tpl_rollup.tsx
CHANGED
|
@@ -143,7 +143,7 @@ export function TplRollup() {
|
|
|
143
143
|
const openLane = open ? open.site.lanes[open.idx] : null;
|
|
144
144
|
|
|
145
145
|
return (
|
|
146
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
146
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
147
147
|
<View style={{ width: "100%", maxWidth: 980, alignSelf: "center", gap: 16 }}>
|
|
148
148
|
{/* header band */}
|
|
149
149
|
<View style={{ gap: 2 }}>
|
|
@@ -89,7 +89,7 @@ export function TplSettings() {
|
|
|
89
89
|
|
|
90
90
|
return (
|
|
91
91
|
<ScrollView
|
|
92
|
-
style={{ flex: 1, backgroundColor: colors.
|
|
92
|
+
style={{ flex: 1, backgroundColor: colors.white }}
|
|
93
93
|
contentContainerStyle={{ padding: 28 }}
|
|
94
94
|
>
|
|
95
95
|
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
package/examples/tpl_shifts.tsx
CHANGED
|
@@ -181,7 +181,7 @@ export function TplShifts() {
|
|
|
181
181
|
filled >= req ? solid("emerald") : filled === 0 ? solid("red") : solid("amber");
|
|
182
182
|
|
|
183
183
|
return (
|
|
184
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
184
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
185
185
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
186
186
|
{/* header band */}
|
|
187
187
|
<View style={{ gap: 2 }}>
|
package/examples/tpl_stock.tsx
CHANGED
|
@@ -188,7 +188,7 @@ export function TplStock() {
|
|
|
188
188
|
];
|
|
189
189
|
|
|
190
190
|
return (
|
|
191
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
191
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
192
192
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
193
193
|
{/* header band */}
|
|
194
194
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
@@ -40,7 +40,7 @@ type TagOption = { value: string; label: string };
|
|
|
40
40
|
// Template · Task board — the manager's view, built on the `DataGrid` primitive
|
|
41
41
|
// (the inline-managed grouped table). The board OWNS its data, state, toolbar,
|
|
42
42
|
// columns and per-group add; `DataGrid` owns the sortable header, the collapsible
|
|
43
|
-
// grouped sections and the aligned rows.
|
|
43
|
+
// grouped sections and the aligned rows.
|
|
44
44
|
// Moderate data only — for 10k+ records you browse, use the paginated register.
|
|
45
45
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
46
|
|
|
@@ -552,6 +552,6 @@ const styles = StyleSheet.create({
|
|
|
552
552
|
result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
|
|
553
553
|
// Fills the cell (height + width) so the link's hit area matches the inline cells.
|
|
554
554
|
action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
|
|
555
|
-
actionHover: { backgroundColor: colors.
|
|
555
|
+
actionHover: { backgroundColor: colors.white },
|
|
556
556
|
source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
557
557
|
});
|
package/examples/tpl_tasks.tsx
CHANGED
|
@@ -426,7 +426,7 @@ const styles = StyleSheet.create({
|
|
|
426
426
|
paddingHorizontal: 8,
|
|
427
427
|
borderRadius: 10,
|
|
428
428
|
},
|
|
429
|
-
rowHover: { backgroundColor: colors.
|
|
429
|
+
rowHover: { backgroundColor: colors.white },
|
|
430
430
|
// The expand affordance — its own button (meta + chevron), sibling to the ring
|
|
431
431
|
// and title so no interactive control nests inside another.
|
|
432
432
|
expand: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8 },
|
|
@@ -206,7 +206,7 @@ export function TplTimeline() {
|
|
|
206
206
|
.filter((d) => d.events.length > 0);
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
209
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
210
210
|
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
211
211
|
{/* header band */}
|
|
212
212
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
package/examples/tpl_tower.tsx
CHANGED
|
@@ -162,7 +162,7 @@ export function TplTower() {
|
|
|
162
162
|
: 0;
|
|
163
163
|
|
|
164
164
|
return (
|
|
165
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
165
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
166
166
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
167
167
|
{/* header band */}
|
|
168
168
|
<View style={{ gap: 2 }}>
|
package/examples/tpl_triage.tsx
CHANGED
|
@@ -54,7 +54,7 @@ export function TplTriage() {
|
|
|
54
54
|
const review = open.filter((r) => r.confidence !== "high").length;
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
57
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
58
58
|
<View style={{ width: "100%", maxWidth: 820, alignSelf: "center", gap: 16 }}>
|
|
59
59
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
60
60
|
<View style={{ flex: 1, gap: 2 }}>
|
package/examples/tpl_wizard.tsx
CHANGED
|
@@ -86,7 +86,7 @@ export function TplWizard() {
|
|
|
86
86
|
const goEdit = (s: number) => setStep(s);
|
|
87
87
|
|
|
88
88
|
return (
|
|
89
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
89
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
90
90
|
<View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
|
|
91
91
|
<View style={{ gap: 2 }}>
|
|
92
92
|
<Text size="xl" weight="semibold">New partner application</Text>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./image_gallery": "./src/image_gallery.tsx",
|
|
30
30
|
"./rotatable_image": "./src/rotatable_image.tsx",
|
|
31
31
|
"./use_image_rotation": "./src/use_image_rotation.ts",
|
|
32
|
+
"./use_selection": "./src/use_selection.ts",
|
|
32
33
|
"./use_selection_mode": "./src/use_selection_mode.ts",
|
|
33
34
|
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
34
35
|
"./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"./section_card": "./src/section_card.tsx",
|
|
45
46
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
46
47
|
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
48
|
+
"./summary_line": "./src/summary_line.tsx",
|
|
47
49
|
"./empty_state": "./src/empty_state.tsx",
|
|
48
50
|
"./format_date": "./src/format_date.ts",
|
|
49
51
|
"./format_money": "./src/format_money.ts",
|
|
@@ -120,6 +122,7 @@
|
|
|
120
122
|
"./badge": "./src/badge.tsx",
|
|
121
123
|
"./callout": "./src/callout.tsx",
|
|
122
124
|
"./inline_edit": "./src/inline_edit.tsx",
|
|
125
|
+
"./inline_static": "./src/inline_static.tsx",
|
|
123
126
|
"./inline_text_input": "./src/inline_text_input.tsx",
|
|
124
127
|
"./inline_number_input": "./src/inline_number_input.tsx",
|
|
125
128
|
"./inline_select": "./src/inline_select.tsx",
|
|
@@ -158,6 +161,7 @@
|
|
|
158
161
|
"./table": "./src/table.tsx",
|
|
159
162
|
"./data_grid": "./src/data_grid.tsx",
|
|
160
163
|
"./detail_row": "./src/detail_row.tsx",
|
|
164
|
+
"./danger_zone": "./src/danger_zone.tsx",
|
|
161
165
|
"./scan_field": "./src/scan_field.tsx",
|
|
162
166
|
"./completion_state": "./src/completion_state.tsx",
|
|
163
167
|
"./remainder_meter": "./src/remainder_meter.tsx",
|
package/src/card.tsx
CHANGED
|
@@ -16,6 +16,10 @@ interface CardProps {
|
|
|
16
16
|
* for a pressable/selectable card use CardSelectItem (a real focusable button),
|
|
17
17
|
* or compose PressableHighlight for a bespoke action.
|
|
18
18
|
*
|
|
19
|
+
* A Card is ALWAYS a card — a bordered, lifted container that groups and
|
|
20
|
+
* separates. A work-execution register that wants a flat look uses NO Card (the
|
|
21
|
+
* register sits directly on the page); there is no "flat card".
|
|
22
|
+
*
|
|
19
23
|
* Two shapes:
|
|
20
24
|
* - Plain: `<Card>` with the default 20px padding, author owns the content.
|
|
21
25
|
* - Banded: `<Card style={{ padding: 0 }}>` composed from the family slots —
|
|
@@ -140,26 +144,15 @@ export function CardFooter(props: CardFooterProps) {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
const styles = StyleSheet.create({
|
|
147
|
+
// The bordered, lifted card — border anchors, shadow lifts; the shadow uses a
|
|
148
|
+
// warm-neutral tint (38,38,38), not pure black (premium signal).
|
|
143
149
|
container: {
|
|
144
150
|
padding: 20,
|
|
145
151
|
borderRadius: 16,
|
|
146
|
-
// Clip banded content (full-bleed rows, their hover/selected wash) to the
|
|
147
|
-
// rounded corners. The CSS box-shadow below is painted outside the border
|
|
148
|
-
// box, so `overflow: hidden` on the same element doesn't clip it — the lift
|
|
149
|
-
// survives. In-card overlays (Peek/ActionMenu/Popover/Tooltip) are
|
|
150
|
-
// portaled, so they aren't clipped either.
|
|
151
152
|
overflow: "hidden",
|
|
152
153
|
backgroundColor: colors.background,
|
|
153
|
-
// 1px hairline + 2-layer shadow: the research-validated default across
|
|
154
|
-
// shadcn/ui, Geist, Tailwind v4. Without either, cards on a near-white
|
|
155
|
-
// page bg float without rhythm. Border anchors; shadow lifts. The shadow
|
|
156
|
-
// uses warm-neutral tint (38,38,38), not pure black — premium signal vs
|
|
157
|
-
// cheap-feeling pure-black shadows.
|
|
158
154
|
borderWidth: 1,
|
|
159
155
|
borderColor: colors.zinc["200"],
|
|
160
|
-
// Slightly more visible than shadcn's `shadow-sm` — chosen because our
|
|
161
|
-
// page bg is `colors.background` (off-white), making `shadow-sm` nearly
|
|
162
|
-
// invisible. Stripe / Linear use this stronger value on similar bg.
|
|
163
156
|
...({
|
|
164
157
|
boxShadow:
|
|
165
158
|
"0 1px 2px 0 rgba(38,38,38,0.06), 0 4px 12px -2px rgba(38,38,38,0.06)",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { solid, tint } from "./colors";
|
|
6
|
+
|
|
7
|
+
export interface DangerZoneProps {
|
|
8
|
+
/** Section heading. Default "Danger zone". */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** A short line spelling out the consequence of the action (it's usually
|
|
11
|
+
* irreversible — say so here). */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** The destructive action(s) — typically a `danger` `Button`. */
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A destructive section set visually APART — for the irreversible record actions
|
|
19
|
+
* (delete, archive, transfer ownership) that belong at the BOTTOM of a record or
|
|
20
|
+
* settings surface, away from the everyday fields. A soft danger-tinted frame +
|
|
21
|
+
* a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
|
|
22
|
+
* discipline, never a raw hex); the consequence goes in `description`, and the
|
|
23
|
+
* action(s) go in `children`.
|
|
24
|
+
*/
|
|
25
|
+
export function DangerZone(props: DangerZoneProps) {
|
|
26
|
+
const { title = "Danger zone", description, children } = props;
|
|
27
|
+
return (
|
|
28
|
+
<View style={styles.zone}>
|
|
29
|
+
<View style={styles.heading}>
|
|
30
|
+
<Icon name="triangle-alert" size={16} color={solid("red")} />
|
|
31
|
+
<Text size="sm" weight="semibold" color="danger">
|
|
32
|
+
{title}
|
|
33
|
+
</Text>
|
|
34
|
+
</View>
|
|
35
|
+
{description ? (
|
|
36
|
+
<Text size="sm" color="muted">
|
|
37
|
+
{description}
|
|
38
|
+
</Text>
|
|
39
|
+
) : null}
|
|
40
|
+
<View style={styles.actions}>{children}</View>
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styles = StyleSheet.create({
|
|
46
|
+
// A soft danger frame: a low-alpha red hairline over the faintest red wash —
|
|
47
|
+
// the "set apart, not shouting" treatment via the kit's tint discipline.
|
|
48
|
+
zone: {
|
|
49
|
+
gap: 8,
|
|
50
|
+
padding: 16,
|
|
51
|
+
borderRadius: 10,
|
|
52
|
+
borderWidth: 1,
|
|
53
|
+
borderColor: tint("red", 0.3),
|
|
54
|
+
backgroundColor: tint("red", 0.03),
|
|
55
|
+
},
|
|
56
|
+
heading: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
57
|
+
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
|
|
58
|
+
});
|
package/src/data_grid.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export interface DataGridProps<T> {
|
|
|
52
52
|
* (search · group-by · filters), the per-group add (`renderGroupFooter`), and every
|
|
53
53
|
* CELL (`columns[].cell` renders ANY field). It renders ALL rows — no pagination /
|
|
54
54
|
* virtualization — so it's for hundreds, low-thousands; at 10k+ use the register.
|
|
55
|
-
* Worked
|
|
55
|
+
* Worked example: `tpl_task_board`.
|
|
56
56
|
*/
|
|
57
57
|
export function DataGrid<T>(props: DataGridProps<T>) {
|
|
58
58
|
const { columns, groups, getRowKey, leading, sort, onSort, labels, collapsed, onToggleCollapse, renderGroupFooter } = props;
|
package/src/detail_row.tsx
CHANGED
|
@@ -7,6 +7,12 @@ export interface DetailRowProps {
|
|
|
7
7
|
label: ReactNode;
|
|
8
8
|
/** The value — any node (a `Text`, a `Badge`, a stack). */
|
|
9
9
|
children: ReactNode;
|
|
10
|
+
/** An optional right-side slot, rendered as the LAST child of the row (after
|
|
11
|
+
* `children`) — a small action `Button`, a status `Badge`, a unit label, an
|
|
12
|
+
* `IconButton`. For it to sit at the right edge, wrap the value/editor in a
|
|
13
|
+
* `flex:1` View so the value takes the slack and the trailing pins right (the
|
|
14
|
+
* inline-record templates do this). */
|
|
15
|
+
trailing?: ReactNode;
|
|
10
16
|
/** Fixed label-column width (px) to align labels in a left column — for a peek
|
|
11
17
|
* or form-like stack. Omit for a spread row: the label takes the slack and the
|
|
12
18
|
* value sits at the right edge (the default drawer-detail look). */
|
|
@@ -24,13 +30,14 @@ export interface DetailRowProps {
|
|
|
24
30
|
* with the values flowing immediately after instead.
|
|
25
31
|
*/
|
|
26
32
|
export function DetailRow(props: DetailRowProps) {
|
|
27
|
-
const { label, children, labelWidth, labelSize = "sm", minHeight = 28 } = props;
|
|
33
|
+
const { label, children, trailing, labelWidth, labelSize = "sm", minHeight = 28 } = props;
|
|
28
34
|
return (
|
|
29
35
|
<View style={[styles.row, { minHeight }]}>
|
|
30
36
|
<Text size={labelSize} color="muted" style={labelWidth != null ? { width: labelWidth } : styles.flexLabel}>
|
|
31
37
|
{label}
|
|
32
38
|
</Text>
|
|
33
39
|
{children}
|
|
40
|
+
{trailing != null ? trailing : null}
|
|
34
41
|
</View>
|
|
35
42
|
);
|
|
36
43
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
|
-
import { View, StyleSheet, type GestureResponderEvent } from "react-native";
|
|
2
|
+
import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { ActivityIndicator } from "./activity_indicator";
|
|
@@ -232,9 +232,19 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
232
232
|
);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
// The
|
|
236
|
-
//
|
|
237
|
-
|
|
235
|
+
// The canonical text treatment for an inline value AT REST — the input's own
|
|
236
|
+
// metrics (font, size, line height, letter spacing) so the value reads
|
|
237
|
+
// identically and occupies the same box in view and edit mode. A read-only
|
|
238
|
+
// `InlineStatic` reuses this so its baseline lines up with the editors.
|
|
239
|
+
export const inlineValueTextStyle: TextStyle = {
|
|
240
|
+
...getInputTextStyle(),
|
|
241
|
+
fontFamily: fontFamilyRegular,
|
|
242
|
+
letterSpacing: -0.4,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// In the view ROW the value text takes the flex slot so the trailing adornment
|
|
246
|
+
// right-aligns; `InlineStatic` uses the base style above without this flex.
|
|
247
|
+
const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
|
|
238
248
|
|
|
239
249
|
const styles = StyleSheet.create({
|
|
240
250
|
view: {
|
package/src/inline_select.tsx
CHANGED
|
@@ -17,8 +17,9 @@ export interface InlineSelectProps<T extends string> {
|
|
|
17
17
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
18
18
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
19
|
/** Render the SELECTED value in the resting view as a node (an avatar chip, a
|
|
20
|
-
* colored badge) instead of its plain label. Falls back to
|
|
21
|
-
*
|
|
20
|
+
* colored badge) instead of its plain label. Falls back to `renderOptionContent`
|
|
21
|
+
* (so the trigger renders like the options), then the plain label, when omitted;
|
|
22
|
+
* the empty state always shows the placeholder. */
|
|
22
23
|
renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
|
|
23
24
|
placeholder?: string;
|
|
24
25
|
disabled?: boolean;
|
|
@@ -28,11 +29,11 @@ export interface InlineSelectProps<T extends string> {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* An inline-editable single-select. The value
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* An inline-editable single-select. The selected value renders like its option
|
|
33
|
+
* (`renderOptionContent`) — a badge stays a badge in the resting trigger, not just
|
|
34
|
+
* text; clicking it floats the option list (an `OptionList`) in a popover anchored
|
|
35
|
+
* to the view, so the row never changes height. Picking commits; dismissing reverts.
|
|
36
|
+
* Pass `renderOptionContent` for rich options, or omit it for a plain label list.
|
|
36
37
|
*/
|
|
37
38
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
38
39
|
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
|
|
@@ -64,7 +65,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
64
65
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
65
66
|
<PopoverTrigger>
|
|
66
67
|
<InlineEditView
|
|
67
|
-
display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
|
|
68
|
+
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
68
69
|
placeholder={placeholder}
|
|
69
70
|
disabled={disabled}
|
|
70
71
|
active={open && !disabled}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
+
import { INLINE_CONTROL_HEIGHT, inlineValueTextStyle } from "./inline_edit";
|
|
6
|
+
|
|
7
|
+
export interface InlineStaticProps {
|
|
8
|
+
/** The value to show. Empty → `placeholder` (default "—"). */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Shown when `value` is empty. Default "—". */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Mute the value (zinc-600) — a secondary or derived figure. */
|
|
13
|
+
muted?: boolean;
|
|
14
|
+
/** Fixed-width (tabular) numerals — for a value sitting in a NUMBER column so
|
|
15
|
+
* its digits line up with the `InlineNumberInput`s above and below it. */
|
|
16
|
+
tabular?: boolean;
|
|
17
|
+
/** Horizontal alignment — `right` for a number column. Default `left`. */
|
|
18
|
+
align?: "left" | "right";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A READ-ONLY value that aligns PIXEL-FOR-PIXEL with the `Inline*` editors in a
|
|
23
|
+
* record: the same control height, horizontal padding, and a 1px TRANSPARENT
|
|
24
|
+
* border, so a non-editable field — a computed total, a system ID, a synced or
|
|
25
|
+
* locked value — sits flush in the same column as the editable rows. It is
|
|
26
|
+
* NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
|
|
27
|
+
* value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
|
|
28
|
+
* editors; wrap it in a `flex:1` View when the row also carries a `trailing`
|
|
29
|
+
* slot, so the value takes the slack and the trailing pins right.
|
|
30
|
+
*/
|
|
31
|
+
export function InlineStatic(props: InlineStaticProps) {
|
|
32
|
+
const { value, placeholder, muted, tabular, align = "left" } = props;
|
|
33
|
+
const isEmpty = value.length === 0;
|
|
34
|
+
const display = isEmpty ? (placeholder ?? "—") : value;
|
|
35
|
+
return (
|
|
36
|
+
<View style={styles.box}>
|
|
37
|
+
<Text
|
|
38
|
+
numberOfLines={1}
|
|
39
|
+
tabular={tabular}
|
|
40
|
+
align={align}
|
|
41
|
+
color={muted ? "muted" : "default"}
|
|
42
|
+
style={[inlineValueTextStyle, isEmpty ? styles.placeholder : null]}
|
|
43
|
+
>
|
|
44
|
+
{display}
|
|
45
|
+
</Text>
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
// Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
|
|
52
|
+
// transparent border) so the value's baseline aligns with the editors — minus
|
|
53
|
+
// the FocusRingPressable, so it never reads as an interactive control.
|
|
54
|
+
box: {
|
|
55
|
+
minHeight: INLINE_CONTROL_HEIGHT,
|
|
56
|
+
borderRadius: CONTROL_RADIUS,
|
|
57
|
+
borderWidth: 1,
|
|
58
|
+
borderColor: "transparent",
|
|
59
|
+
paddingHorizontal: 8,
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
},
|
|
62
|
+
placeholder: { color: colors.zinc[400] },
|
|
63
|
+
});
|