@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.
Files changed (63) hide show
  1. package/AGENTS.md +92 -43
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record_plain.tsx +121 -16
  26. package/examples/tpl_report.tsx +1 -1
  27. package/examples/tpl_rollup.tsx +1 -1
  28. package/examples/tpl_settings.tsx +1 -1
  29. package/examples/tpl_shifts.tsx +1 -1
  30. package/examples/tpl_stock.tsx +1 -1
  31. package/examples/tpl_task_board.tsx +2 -2
  32. package/examples/tpl_tasks.tsx +1 -1
  33. package/examples/tpl_timeline.tsx +1 -1
  34. package/examples/tpl_tower.tsx +1 -1
  35. package/examples/tpl_triage.tsx +1 -1
  36. package/examples/tpl_wizard.tsx +1 -1
  37. package/package.json +5 -1
  38. package/src/card.tsx +6 -13
  39. package/src/danger_zone.tsx +58 -0
  40. package/src/data_grid.tsx +1 -1
  41. package/src/detail_row.tsx +8 -1
  42. package/src/inline_edit.tsx +14 -4
  43. package/src/inline_select.tsx +9 -8
  44. package/src/inline_static.tsx +63 -0
  45. package/src/kpi_strip.tsx +5 -5
  46. package/src/pressable_row.tsx +22 -10
  47. package/src/sort_header.tsx +9 -4
  48. package/src/summary_line.tsx +76 -0
  49. package/src/table.tsx +27 -11
  50. package/src/use_selection.test.ts +66 -0
  51. package/src/use_selection.ts +75 -0
  52. package/examples/app_orders.tsx +0 -405
  53. package/examples/tpl_approvals.tsx +0 -375
  54. package/examples/tpl_batch.tsx +0 -234
  55. package/examples/tpl_callsheet.tsx +0 -481
  56. package/examples/tpl_convert.tsx +0 -478
  57. package/examples/tpl_crm_desk.tsx +0 -541
  58. package/examples/tpl_dispatch.tsx +0 -289
  59. package/examples/tpl_inventory.tsx +0 -299
  60. package/examples/tpl_pipeline.tsx +0 -400
  61. package/examples/tpl_reconcile.tsx +0 -275
  62. package/examples/tpl_record.tsx +0 -302
  63. 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) — the same in-place record edit as the card
17
- // variant, but CARD-LESS: sections are set off by a clear `SectionHeading` + a
18
- // hairline and generous vertical rhythm, not card chrome. The lighter look for
19
- // a single full-screen record where banded cards would be visual noise.
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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
133
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
87
134
  <View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
88
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
89
- <Text size="xl" weight="semibold">
90
- {name}
91
- </Text>
92
- <Badge label="Customer" color="blue" />
93
- <View style={{ flex: 1 }} />
94
- <Text size="xs" color="muted">
95
- Click any value to edit
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
  );
@@ -217,7 +217,7 @@ export function TplReport() {
217
217
  ];
218
218
 
219
219
  return (
220
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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.zinc[50] }}
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -188,7 +188,7 @@ export function TplStock() {
188
188
  ];
189
189
 
190
190
  return (
191
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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. Sibling: tpl_pipeline (same shape, deals).
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.zinc[50] },
555
+ actionHover: { backgroundColor: colors.white },
556
556
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
557
557
  });
@@ -426,7 +426,7 @@ const styles = StyleSheet.create({
426
426
  paddingHorizontal: 8,
427
427
  borderRadius: 10,
428
428
  },
429
- rowHover: { backgroundColor: colors.zinc[50] },
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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -162,7 +162,7 @@ export function TplTower() {
162
162
  : 0;
163
163
 
164
164
  return (
165
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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.8.0",
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 examples: `tpl_task_board`, `tpl_pipeline`.
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;
@@ -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
  }
@@ -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 view text reuses the input's own canonical metrics (font, size, spacing)
236
- // so the value reads identically and occupies the same box in both modes.
237
- const viewTextStyle = [getInputTextStyle(), { flex: 1, fontFamily: fontFamilyRegular, letterSpacing: -0.4 }];
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: {
@@ -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 the label when
21
- * omitted; the empty state always shows the placeholder. */
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 reads as plain text; clicking it
32
- * floats the option list (an `OptionList`) in a popover anchored to the view, so
33
- * the row never changes height. Picking commits; dismissing reverts. Pass
34
- * `renderOptionContent` for rich options, or omit it for a plain list the
35
- * standard and custom-rendered pickers are both supported here.
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
+ });