@lotics/ui 7.8.0 → 7.9.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 (58) hide show
  1. package/AGENTS.md +71 -32
  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.tsx +1 -1
  26. package/examples/tpl_record_plain.tsx +1 -1
  27. package/examples/tpl_report.tsx +1 -1
  28. package/examples/tpl_rollup.tsx +1 -1
  29. package/examples/tpl_settings.tsx +1 -1
  30. package/examples/tpl_shifts.tsx +1 -1
  31. package/examples/tpl_stock.tsx +1 -1
  32. package/examples/tpl_task_board.tsx +2 -2
  33. package/examples/tpl_tasks.tsx +1 -1
  34. package/examples/tpl_timeline.tsx +1 -1
  35. package/examples/tpl_tower.tsx +1 -1
  36. package/examples/tpl_triage.tsx +1 -1
  37. package/examples/tpl_wizard.tsx +1 -1
  38. package/package.json +3 -1
  39. package/src/card.tsx +6 -13
  40. package/src/data_grid.tsx +1 -1
  41. package/src/kpi_strip.tsx +5 -5
  42. package/src/pressable_row.tsx +22 -10
  43. package/src/sort_header.tsx +9 -4
  44. package/src/summary_line.tsx +76 -0
  45. package/src/table.tsx +27 -11
  46. package/src/use_selection.test.ts +66 -0
  47. package/src/use_selection.ts +75 -0
  48. package/examples/app_orders.tsx +0 -405
  49. package/examples/tpl_approvals.tsx +0 -375
  50. package/examples/tpl_batch.tsx +0 -234
  51. package/examples/tpl_callsheet.tsx +0 -481
  52. package/examples/tpl_convert.tsx +0 -478
  53. package/examples/tpl_crm_desk.tsx +0 -541
  54. package/examples/tpl_dispatch.tsx +0 -289
  55. package/examples/tpl_inventory.tsx +0 -299
  56. package/examples/tpl_pipeline.tsx +0 -400
  57. package/examples/tpl_reconcile.tsx +0 -275
  58. package/examples/tpl_run.tsx +0 -235
@@ -83,7 +83,7 @@ export function TplRecordPlain() {
83
83
  const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
84
84
 
85
85
  return (
86
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
86
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
87
87
  <View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
88
88
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
89
89
  <Text size="xl" weight="semibold">
@@ -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.9.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",
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)",
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;
package/src/kpi_strip.tsx CHANGED
@@ -34,11 +34,11 @@ export interface KPIStripProps {
34
34
  }
35
35
 
36
36
  /**
37
- * THE stat band at the top of a screen — one Card, hairline-divided columns
38
- * of KPICard with a uniform value scale. Screens must not hand-compose stat
39
- * strips: the same four-numbers-in-a-row should look identical on a
40
- * dashboard, an inventory register, or a payroll page. Wraps on narrow
41
- * screens (columns keep a 180px floor).
37
+ * THE dashboard stat band — one Card, hairline-divided columns of KPICard with a
38
+ * uniform value scale, at the TOP of a DASHBOARD page. It is NOT for register /
39
+ * list pages: a list summarizes the FILTERED view with the light inline
40
+ * `SummaryLine` below its toolbar, never this boxed metric grid. Screens must
41
+ * not hand-compose stat bands. Wraps on narrow screens (columns keep a 180px floor).
42
42
  */
43
43
  export function KPIStrip(props: KPIStripProps) {
44
44
  const { items, size = "lg" } = props;
@@ -11,16 +11,19 @@ export interface PressableRowProps {
11
11
  * it's the resting state of a ticked row in a bulk-select register. */
12
12
  marked?: boolean;
13
13
  /**
14
- * - "bleed" (default): the standalone REGISTER row px-20, square wash to
15
- * the card edges, separated by full-bleed `Divider`s. The one pattern for
16
- * top-level record lists.
14
+ * - "register" (THE record-list default): a rounded row whose hover/open/`marked`
15
+ * wash spans the FULL row width (incl. nested controls); content stays on the
16
+ * 20px column gutter so a `Table` header and its cells still align. The `Table`
17
+ * separates rows with a `Divider` (the wash is the hover/selection state, the
18
+ * hairline the resting separation) — every app inherits this register look.
19
+ * - "bleed" (legacy): px-20, square wash to the edges, `Divider`-separated.
20
+ * The pre-rounded register row — kept for an edge-to-edge data grid that
21
+ * genuinely wants hard rules, not the floating default.
17
22
  * - "inset": a rounded, contained row — the wash is a rounded pill pulled in
18
23
  * from the edge (radius 8, marginH -8). Lives inside a padded container (an
19
- * `Accordion`'s drill-down rows, or a gap-separated selectable list) so the
20
- * wash insets from the card edge instead of bleeding to it. Use when rows are
21
- * GAP-separated rather than `Divider`-separated.
24
+ * `Accordion`'s drill-down rows, or a gap-separated selectable list).
22
25
  */
23
- variant?: "bleed" | "inset";
26
+ variant?: "register" | "bleed" | "inset";
24
27
  /** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
25
28
  * claim their own presses; put the accessible door inside — a plain
26
29
  * `Pressable` with role="button" + "Open …" label around the row body. */
@@ -42,7 +45,7 @@ export interface PressableRowProps {
42
45
  * `variant` picks bleed (registers) or inset (Accordion drill-down rows).
43
46
  */
44
47
  export function PressableRow(props: PressableRowProps) {
45
- const { onPress, selected = false, marked = false, variant = "bleed", children, style } = props;
48
+ const { onPress, selected = false, marked = false, variant = "register", children, style } = props;
46
49
  const [hovered, setHovered] = useState(false);
47
50
 
48
51
  // RN's types don't declare mouse handlers; react-native-web forwards them
@@ -62,7 +65,7 @@ export function PressableRow(props: PressableRowProps) {
62
65
  {...mouseProps}
63
66
  style={({ pressed }) => [
64
67
  styles.row,
65
- variant === "inset" ? styles.inset : styles.bleed,
68
+ variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
66
69
  {
67
70
  backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
68
71
  },
@@ -81,7 +84,16 @@ const styles = StyleSheet.create({
81
84
  gap: 12,
82
85
  ...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
83
86
  },
84
- // Square, full-bleedlines up with the Dividers between register rows.
87
+ // THE register row FULL-WIDTH rounded highlight. The hover/selected wash
88
+ // spans the entire row (so the pressable area === the row, never narrower than
89
+ // the dividers around it) and rounds (radius 10); content sits on the 20px
90
+ // column gutter so a Table header + cells align. The Divider between rows (the
91
+ // Table renders it) is the resting separation; this wash is the hover state.
92
+ register: {
93
+ borderRadius: 10,
94
+ paddingHorizontal: 20,
95
+ },
96
+ // Square, full-bleed — lines up with the Dividers between register rows. Legacy.
85
97
  bleed: {
86
98
  paddingHorizontal: 20,
87
99
  },
@@ -77,18 +77,23 @@ export function SortHeader(props: SortHeaderProps) {
77
77
  const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
78
78
  const loc = useLoticsLocale().sortHeader;
79
79
  const active = sort?.key === sortKey;
80
- const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
80
+ // Always show a sort glyph so every sortable column READS as sortable — a faint
81
+ // up/down hint at rest, the solid direction chevron once active. (Before, the
82
+ // affordance only appeared after sorting, so columns looked un-sortable.)
83
+ const glyph = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down";
84
+ const glyphColor = active ? colors.zinc[600] : colors.zinc[400];
81
85
  const dirText = active ? (sort.dir === "asc" ? (labels?.ascending ?? loc.ascending) : (labels?.descending ?? loc.descending)) : "";
82
86
  const sortByLabel = (labels?.sortBy ?? loc.sortBy)(label);
83
87
 
84
88
  return (
85
89
  <PressableHighlight
86
- focusRing accessibilityRole="button"
90
+ focusRing
91
+ accessibilityRole="button"
87
92
  accessibilityLabel={`${sortByLabel}${dirText}`}
88
93
  onPress={() => onSort(sortKey)}
89
94
  style={[styles.header, align === "right" ? styles.right : null, style]}
90
95
  >
91
- {align === "right" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
96
+ {align === "right" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
92
97
  <Text
93
98
  size="xs"
94
99
  color={active ? "default" : "muted"}
@@ -98,7 +103,7 @@ export function SortHeader(props: SortHeaderProps) {
98
103
  >
99
104
  {label}
100
105
  </Text>
101
- {align === "left" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
106
+ {align === "left" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
102
107
  </PressableHighlight>
103
108
  );
104
109
  }
@@ -0,0 +1,76 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Metric, type MetricFormat, type MetricTone } from "./metric";
4
+ import { TrendChip } from "./trend_chip";
5
+ import { InfoPopover } from "./info_popover";
6
+
7
+ export interface SummaryLineItem {
8
+ /** Label that reads AFTER the value ("below minimum", "stock value"). */
9
+ label: string;
10
+ value: number | string | null | undefined;
11
+ format?: MetricFormat;
12
+ currency?: string;
13
+ locale?: string;
14
+ emptyLabel?: string;
15
+ tone?: MetricTone;
16
+ /** Abbreviate large values (≥1 triệu → "486 tr"). */
17
+ compact?: boolean;
18
+ /** Optional ±% trend chip. */
19
+ trend?: number | null;
20
+ /** What this metric means — the ⓘ next to the value opens it in a popover. */
21
+ info?: string;
22
+ }
23
+
24
+ export interface SummaryLineProps {
25
+ items: SummaryLineItem[];
26
+ }
27
+
28
+ /**
29
+ * A LIGHT, inline "value label" summary of the CURRENT view — sits BETWEEN the
30
+ * toolbar and a register/list and is recomputed from the FILTERED rows, so it
31
+ * always describes WHAT'S SHOWN (not the whole dataset). This is NOT the
32
+ * dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
33
+ * pages, left untouched). Here there's no card, no big numbers, no column rules
34
+ * — a small `Metric` value + a muted label, items wrapping, separated by space.
35
+ */
36
+ export function SummaryLine(props: SummaryLineProps) {
37
+ const { items } = props;
38
+ return (
39
+ <View style={styles.row}>
40
+ {items.map((item) => (
41
+ <View key={item.label} style={styles.item}>
42
+ <Metric
43
+ value={item.value}
44
+ format={item.format}
45
+ currency={item.currency}
46
+ locale={item.locale}
47
+ emptyLabel={item.emptyLabel}
48
+ compact={item.compact}
49
+ tone={item.tone}
50
+ size="sm"
51
+ />
52
+ <Text size="sm" color="muted">
53
+ {item.label}
54
+ </Text>
55
+ {item.trend != null ? <TrendChip value={item.trend} /> : null}
56
+ {item.info ? <InfoPopover text={item.info} accessibilityLabel={`About ${item.label}`} /> : null}
57
+ </View>
58
+ ))}
59
+ </View>
60
+ );
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ row: {
65
+ flexDirection: "row",
66
+ flexWrap: "wrap",
67
+ alignItems: "center",
68
+ columnGap: 22,
69
+ rowGap: 8,
70
+ },
71
+ item: {
72
+ flexDirection: "row",
73
+ alignItems: "center",
74
+ gap: 6,
75
+ },
76
+ });
package/src/table.tsx CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  } from "react";
10
10
  import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
11
11
  import { Text } from "./text";
12
- import { Divider } from "./divider";
12
+ import { colors } from "./colors";
13
13
  import { PressableRow } from "./pressable_row";
14
+ import { Divider } from "./divider";
14
15
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
15
16
  import { FOCUS_RING } from "./control_surface";
16
17
  import { useFocusRing } from "./use_focus_ring";
@@ -54,6 +55,9 @@ export interface TableProps {
54
55
  onSort?: (key: string) => void;
55
56
  /** Localized a11y strings for the sortable headers. Omit to keep English. */
56
57
  sortLabels?: SortHeaderLabels;
58
+ /** A node for the header's leading gutter — a select-all `CheckboxInput` aligned over
59
+ * the rows' `leading` checkboxes. Set `leading` too, to reserve the gutter width. */
60
+ selectAll?: ReactNode;
57
61
  /** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
58
62
  leading?: number;
59
63
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
@@ -71,13 +75,13 @@ export interface TableProps {
71
75
  * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
72
76
  */
73
77
  export function Table(props: TableProps) {
74
- const { columns, sort, onSort, sortLabels, leading = 0, trailing = 0, children } = props;
78
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
75
79
  const rows = Children.toArray(children).filter(isValidElement);
76
80
 
77
81
  return (
78
82
  <TableContext.Provider value={{ columns, leading, trailing }}>
79
83
  <View style={styles.headerBand}>
80
- {leading > 0 ? <View style={{ width: leading }} /> : null}
84
+ {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
81
85
  {columns.map((col) => (
82
86
  <View key={col.key} style={colStyle(col)}>
83
87
  {col.label ? (
@@ -93,12 +97,14 @@ export function Table(props: TableProps) {
93
97
  ))}
94
98
  {trailing > 0 ? <View style={{ width: trailing }} /> : null}
95
99
  </View>
96
- {rows.map((row, i) => (
97
- <View key={(row as ReactElement).key ?? i}>
98
- <Divider />
99
- {row}
100
- </View>
101
- ))}
100
+ <View style={styles.body}>
101
+ {rows.map((row, i) => (
102
+ <View key={i}>
103
+ {i > 0 ? <Divider /> : null}
104
+ {row}
105
+ </View>
106
+ ))}
107
+ </View>
102
108
  </TableContext.Provider>
103
109
  );
104
110
  }
@@ -107,6 +113,8 @@ export interface TableRowProps {
107
113
  /** Opens the record (the drawer). The door + the full-row surface both call it. */
108
114
  onPress?: () => void;
109
115
  selected?: boolean;
116
+ /** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
117
+ marked?: boolean;
110
118
  /** Accessible name for the door (the keyboard-focusable open target). */
111
119
  accessibilityLabel?: string;
112
120
  /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
@@ -127,7 +135,7 @@ export interface TableRowProps {
127
135
  * win. Cells map to `Table`'s columns by position.
128
136
  */
129
137
  export function TableRow(props: TableRowProps) {
130
- const { onPress, selected, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
138
+ const { onPress, selected, marked, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
131
139
  const ctx = useContext(TableContext);
132
140
  if (!ctx) throw new Error("TableRow must be used within a Table");
133
141
 
@@ -137,7 +145,7 @@ export function TableRow(props: TableRowProps) {
137
145
  const { focusVisible, focusProps } = useFocusRing();
138
146
 
139
147
  return (
140
- <PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
148
+ <PressableRow onPress={onPress ?? noop} selected={selected} marked={marked} style={styles.row}>
141
149
  {ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
142
150
  <Pressable
143
151
  accessibilityRole={onPress ? "button" : undefined}
@@ -168,12 +176,20 @@ export function TableCell(props: TableCellProps) {
168
176
  const noop = () => {};
169
177
 
170
178
  const styles = StyleSheet.create({
179
+ // A hairline under the column header anchors the columns; the rows below it are
180
+ // Divider-separated.
171
181
  headerBand: {
172
182
  paddingHorizontal: 20,
173
183
  paddingVertical: 10,
174
184
  flexDirection: "row",
175
185
  alignItems: "center",
176
186
  gap: 14,
187
+ borderBottomWidth: 1,
188
+ borderColor: colors.border,
189
+ },
190
+ // Rows are Divider-separated — the hairline between rows is the separation.
191
+ body: {
192
+ gap: 0,
177
193
  },
178
194
  row: {
179
195
  gap: 14,
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { useSelection } from "./use_selection";
5
+
6
+ describe("useSelection", () => {
7
+ it("toggles an id on and off, and sets it explicitly with `on`", () => {
8
+ const { result } = renderHook(() => useSelection());
9
+ expect(result.current.count).toBe(0);
10
+ expect(result.current.has("a")).toBe(false);
11
+
12
+ act(() => result.current.toggle("a"));
13
+ expect(result.current.has("a")).toBe(true);
14
+ expect(result.current.count).toBe(1);
15
+
16
+ act(() => result.current.toggle("a"));
17
+ expect(result.current.has("a")).toBe(false);
18
+
19
+ act(() => result.current.toggle("a", true));
20
+ act(() => result.current.toggle("a", true)); // explicit on is idempotent
21
+ expect(result.current.count).toBe(1);
22
+ act(() => result.current.toggle("a", false));
23
+ expect(result.current.has("a")).toBe(false);
24
+ });
25
+
26
+ it("setAll selects/deselects a batch; `selected` exposes the picked ids", () => {
27
+ const { result } = renderHook(() => useSelection());
28
+ act(() => result.current.setAll(["a", "b", "c"], true));
29
+ expect(result.current.count).toBe(3);
30
+ expect([...result.current.selected].sort()).toEqual(["a", "b", "c"]);
31
+
32
+ act(() => result.current.setAll(["a", "b"], false));
33
+ expect([...result.current.selected]).toEqual(["c"]);
34
+ });
35
+
36
+ it("allSelected / indeterminate drive a select-all checkbox", () => {
37
+ const { result } = renderHook(() => useSelection());
38
+ const page = ["a", "b", "c"];
39
+ expect(result.current.allSelected(page)).toBe(false);
40
+ expect(result.current.indeterminate(page)).toBe(false);
41
+ expect(result.current.allSelected([])).toBe(false); // an empty set is never "all"
42
+
43
+ act(() => result.current.toggle("a", true));
44
+ expect(result.current.allSelected(page)).toBe(false);
45
+ expect(result.current.indeterminate(page)).toBe(true);
46
+
47
+ act(() => result.current.setAll(page, true));
48
+ expect(result.current.allSelected(page)).toBe(true);
49
+ expect(result.current.indeterminate(page)).toBe(false);
50
+ });
51
+
52
+ it("only the passed ids count — a selection outside the set isn't 'all'/'indeterminate' for it", () => {
53
+ const { result } = renderHook(() => useSelection());
54
+ act(() => result.current.setAll(["x", "y"], true));
55
+ expect(result.current.allSelected(["a", "b"])).toBe(false);
56
+ expect(result.current.indeterminate(["a", "b"])).toBe(false);
57
+ });
58
+
59
+ it("clear empties the whole selection", () => {
60
+ const { result } = renderHook(() => useSelection());
61
+ act(() => result.current.setAll(["a", "b"], true));
62
+ act(() => result.current.clear());
63
+ expect(result.current.count).toBe(0);
64
+ expect(result.current.has("a")).toBe(false);
65
+ });
66
+ });
@@ -0,0 +1,75 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ export interface Selection {
4
+ /** The selected ids — read `.has(id)` / `.size`, or use the helpers below.
5
+ * Spread it (`[...selected]`) to hand the picked ids to a bulk action. */
6
+ selected: ReadonlySet<string>;
7
+ /** Whether `id` is currently selected. */
8
+ has: (id: string) => boolean;
9
+ /** How many ids are selected — across pages, so it's the running total a
10
+ * `FloatingActionBar` shows. */
11
+ count: number;
12
+ /** Toggle `id`, or set it explicitly when `on` is passed (a row checkbox's `onChange`). */
13
+ toggle: (id: string, on?: boolean) => void;
14
+ /** Set a whole batch of ids on/off at once — the controlled select-all `onChange(on)`. */
15
+ setAll: (ids: readonly string[], on: boolean) => void;
16
+ /** Are ALL of `ids` selected? — a select-all checkbox's `checked` (false for an empty set). */
17
+ allSelected: (ids: readonly string[]) => boolean;
18
+ /** SOME but not all of `ids` selected? — a select-all checkbox's `indeterminate`. */
19
+ indeterminate: (ids: readonly string[]) => boolean;
20
+ /** Clear the whole selection. */
21
+ clear: () => void;
22
+ }
23
+
24
+ /**
25
+ * Always-on multi-select state for a register / list: the `selected` Set plus the
26
+ * toggle, select-all, and all-or-some helpers a `Table` + `FloatingActionBar` need.
27
+ * Items are agnostic string ids — selectability GATING (which rows can be ticked)
28
+ * stays with the caller: filter to the selectable ids and pass THOSE to `setAll` /
29
+ * `allSelected` / `indeterminate` (a non-selectable row just gets a disabled
30
+ * checkbox). Selection persists across pages, so `count` is the running total.
31
+ * (For a files-grid that ENTERS a selection mode via tap-and-hold, that's
32
+ * `useSelectionMode`; this is the checkbox-always-visible register variant.)
33
+ */
34
+ export function useSelection(): Selection {
35
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
36
+
37
+ const toggle = useCallback((id: string, on?: boolean) => {
38
+ setSelected((prev) => {
39
+ const next = new Set(prev);
40
+ if (on ?? !prev.has(id)) next.add(id);
41
+ else next.delete(id);
42
+ return next;
43
+ });
44
+ }, []);
45
+
46
+ const setAll = useCallback((ids: readonly string[], on: boolean) => {
47
+ setSelected((prev) => {
48
+ const next = new Set(prev);
49
+ for (const id of ids) {
50
+ if (on) next.add(id);
51
+ else next.delete(id);
52
+ }
53
+ return next;
54
+ });
55
+ }, []);
56
+
57
+ const clear = useCallback(() => setSelected(new Set()), []);
58
+
59
+ return useMemo<Selection>(
60
+ () => ({
61
+ selected,
62
+ has: (id) => selected.has(id),
63
+ count: selected.size,
64
+ toggle,
65
+ setAll,
66
+ allSelected: (ids) => ids.length > 0 && ids.every((id) => selected.has(id)),
67
+ indeterminate: (ids) => {
68
+ const all = ids.length > 0 && ids.every((id) => selected.has(id));
69
+ return !all && ids.some((id) => selected.has(id));
70
+ },
71
+ clear,
72
+ }),
73
+ [selected, toggle, setAll, clear],
74
+ );
75
+ }