@lotics/ui 7.0.0 → 7.2.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 CHANGED
@@ -97,7 +97,9 @@ Pick by capability, not by name. (→ the source file for the API.)
97
97
  recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
98
98
  (a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
99
99
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in a row),
100
- `StatusGrid` + `StatusLegend`, `Heatmap`.
100
+ `StatusGrid` + `StatusLegend`, `Heatmap` (density: colour-only, "where does it cluster"),
101
+ `Matrix` (the PIVOT cross-tab: the NUMBER in each cell — optionally a heat wash behind it — plus
102
+ row/column/grand totals; press a cell to drill).
101
103
  - **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
102
104
  `CardFooter`), `SectionCard`, `SectionHeading` (+ `SectionHeadingTitle`), `PageHeader` /
103
105
  `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`.
@@ -171,7 +173,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
171
173
  ### Inline edit — the preferred way to edit an existing record
172
174
  When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
173
175
  form mode or a preview↔edit card — make each VALUE inline-editable: it reads as plain text,
174
- hover tints it (no pencil that shifts), click swaps the input in **at the same height** (zero
176
+ hover reveals its border (it's a field — no grey tint, no pencil that shifts), click swaps the input in **at the same height** (zero
175
177
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
176
178
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
177
179
  (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
@@ -547,9 +549,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
547
549
  resets the native outline. Every interactive control paints its OWN ring; nothing rings unless it
548
550
  opts in. Three ways, pick by base:
549
551
  - **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on keyboard
550
- focus; the default for a raw interactive element (cell, tile, nav button, menu option). Forwards
551
- all `PressableProps` (onPress, accessibilityRole/Label/State, ref, style). Reach for this before
552
- hand-rolling a Pressable + the hook.
552
+ focus, WITHOUT the hover wash; the base for any control whose hover affordance is its BORDER not a wash
553
+ (inputs/selects, the inline editors, cells, tiles, nav buttons, menu options). Forwards all
554
+ `PressableProps`; its state-fn `style` exposes `hovered`/`focusVisible` so the control paints its own
555
+ border-hover. Reach for this before hand-rolling a Pressable + the hook.
553
556
  - **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible` from
554
557
  its style-fn / children for a bespoke treatment, e.g. `CardSelectItem` which rings on hover/press/focus).
555
558
  - **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom surfaces.
@@ -562,14 +565,26 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
562
565
  its open state so it reads identically to a keyboard-focused control. **Coverage is a contract:** EVERY
563
566
  interactive control must ring (a focusable control with no focus treatment is a bug — the per-Pressable
564
567
  audit is the gate); a surface that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
565
- - **A trigger's hover/focus is a contract too build it on `PressableHighlight`, never a raw `Pressable`.**
566
- Every popover/menu/select anchor (`Select`, `FilterChip`, `Switcher`, the inline editors, a custom
567
- `PopoverTrigger`) rides `PressableHighlight` (or `FocusRingPressable`): it paints the hover **wash** +
568
- focus ring AROUND the caller's `style`, so a borderless in-cell override (`style={{ borderColor:
569
- "transparent" }}` on a `Select`) keeps the wash and reads like its inline neighbours. A raw `Pressable`
570
- that hand-rolls its own hover is the bug a caller's `borderColor` then erases the affordance. Layer the
571
- hover-border BEFORE `style` (a transparent edge suppresses it wash only) and the open ring (`FOCUS_RING`)
572
- on the open state.
568
+ - **Hover intensifies a control's OWN resting signature — never a foreign affordance. Pick the base by WHAT THE SURFACE IS; never a raw `Pressable`.** The one rule behind every interactive control:
569
+ - **Fields** (type into / pick from) — `TextInputField`, `Picker`, `Select`, `Combobox`'s input,
570
+ `NumberInput`, `DateField`/`TimePicker`, `DateRangeFilterField`, the inline editors
571
+ (`InlineSelect`/`InlineDatePicker` via `InlineEditView`) signature is a BORDER hover DARKENS it to
572
+ `HOVER_BORDER` (a borderless in-cell field REVEALS one). Build on **`FocusRingPressable`** (ring + a11y
573
+ + a `state.hovered`/`focusVisible` style-fn, NO wash); layer the hover-border AFTER `style` so it wins
574
+ over the caller's resting edge; put the open ring (`FOCUS_RING`) on the open state. **A field NEVER
575
+ greys its content.**
576
+ - **Actions** — `Button`, `IconButton` — signature is a FILL → hover DARKENS the fill (own colour logic).
577
+ Never a border, never a wash.
578
+ - **Pills / toggles** — `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`,
579
+ `Tabs`, `Switcher`, `RadioPicker` — signature is a pill SURFACE → hover WASHES it (white→zinc-100);
580
+ selected adds a ring/fill.
581
+ - **Surfaces** — `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Card`, `Accordion` —
582
+ signature is a row/card SURFACE → hover WASHES it. Build on **`PressableHighlight`** — the grey wash IS
583
+ the affordance (its whole job; it's in the name).
584
+ - The bug this prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border →
585
+ it greys *and* animates its edge while its siblings only border-hover (`Select`/`DateRangeFilterField`/
586
+ the inline editors did exactly this pre-7.x). Reach for `PressableHighlight` on a bordered/input control
587
+ and you've made it.
573
588
  - **Cards are banded — and composable** (all from `@lotics/ui/card`):
574
589
  ```tsx
575
590
  <Card style={{ padding: 0 }}>
@@ -700,8 +715,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
700
715
  - **Monitor & decide** — `tpl_dashboard` (KPIStrip + trends + attention list) · `tpl_stock`
701
716
  (10k-record faceted funnel) · `tpl_report` (scope-first lookup report — a header period +
702
717
  one-of-N dimension search drives KPIs → aligned facets w/ a `maxRows` long-tail toggle →
703
- paginated register → export) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_rollup`
704
- (hierarchical totals).
718
+ paginated register → export) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_pivot` (the
719
+ `Matrix` cross-tab desk: one dimension × another, number + heat per cell + row/col/grand totals,
720
+ press a cell to drill the list behind it) · `tpl_rollup` (hierarchical totals).
705
721
  - **Work execution** — `app_orders` (stage-gated work queue) · `tpl_crm_desk` (assign & work +
706
722
  `FloatingActionBar`) · `tpl_callsheet` (call console) · `tpl_convert` (staged conversion) ·
707
723
  `tpl_inventory` (threshold actions) · `tpl_dossier` (high-volume paginated register) ·
@@ -785,7 +801,7 @@ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable
785
801
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
786
802
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
787
803
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
788
- status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_meter · allocation_row ·
804
+ status_grid (StatusGrid + StatusLegend) · heatmap (density cross-tab — colour-only, no numbers) · matrix (Matrix — the PIVOT cross-tab: band-compound `Matrix` root + `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) + `Matrix.Legend`; the data layer `matrixTotals`/`MatrixAxisItem`/`MatrixCellRef` import from `@lotics/ui/matrix_totals`. Pick over `Heatmap` when the NUMBER and totals matter, not just where it clusters) · legend_item · remainder_meter · allocation_row ·
789
805
  scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done) + built-in gallery + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview ·
790
806
  file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
791
807
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
@@ -0,0 +1,249 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Badge } from "@lotics/ui/badge";
6
+ import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
+ import { Divider } from "@lotics/ui/divider";
8
+ import { Drawer } from "@lotics/ui/drawer";
9
+ import { EmptyState } from "@lotics/ui/empty_state";
10
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
11
+ import { Chip } from "@lotics/ui/chip";
12
+ import { Matrix } from "@lotics/ui/matrix";
13
+ import { type MatrixCellRef } from "@lotics/ui/matrix_totals";
14
+ import { PressableRow } from "@lotics/ui/pressable_row";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Template · Pivot desk — one dimension crossed against another, then drill.
18
+ // The Matrix is the hero: warehouse × category, the number IN each cell and a
19
+ // heat wash behind it so the concentration reads at a glance, row/column/grand
20
+ // totals down the edges. Pressing a cell is a door — the register below filters
21
+ // to exactly that intersection; a row there opens the item drawer. The pattern
22
+ // for "how does X distribute across Y, and what's behind each bucket?".
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ const WAREHOUSES = [
26
+ { key: "chi", label: "Chicago" },
27
+ { key: "dal", label: "Dallas" },
28
+ { key: "nwk", label: "Newark" },
29
+ { key: "reno", label: "Reno" },
30
+ { key: "atl", label: "Atlanta" },
31
+ ] as const;
32
+
33
+ const CATEGORIES = [
34
+ { key: "apparel", label: "Apparel" },
35
+ { key: "home", label: "Home" },
36
+ { key: "elec", label: "Electronics" },
37
+ { key: "outdoor", label: "Outdoor" },
38
+ ] as const;
39
+
40
+ type WarehouseKey = (typeof WAREHOUSES)[number]["key"];
41
+ type CategoryKey = (typeof CATEGORIES)[number]["key"];
42
+
43
+ interface Sku {
44
+ code: string;
45
+ name: string;
46
+ warehouse: WarehouseKey;
47
+ category: CategoryKey;
48
+ units: number;
49
+ status: "in_stock" | "low" | "backorder";
50
+ }
51
+
52
+ // Deterministic mock catalogue — integer hashing, stable across reloads.
53
+ function hash(i: number, salt: number): number {
54
+ let x = (i + 1) * 2654435761 + salt * 40503;
55
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
56
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
57
+ return (x >>> 16) % 1000;
58
+ }
59
+
60
+ const NAMES = ["Cotton tee", "Wool throw", "USB hub", "Trail flask", "Rain shell", "Desk lamp", "Sport sock", "Cast pan", "Earbuds", "Tent peg", "Linen shirt", "Throw pillow"];
61
+
62
+ const SKUS: Sku[] = (() => {
63
+ const out: Sku[] = [];
64
+ // A deliberate concentration in Newark · Electronics so a hot cell has a story.
65
+ for (let i = 0; i < 64; i++) {
66
+ const wRoll = hash(i, 1) % 100;
67
+ const warehouse: WarehouseKey =
68
+ wRoll < 18 ? "chi" : wRoll < 36 ? "dal" : wRoll < 66 ? "nwk" : wRoll < 84 ? "reno" : "atl";
69
+ const cRoll = hash(i, 2) % 100;
70
+ const category: CategoryKey =
71
+ warehouse === "nwk" && cRoll < 55 ? "elec" : cRoll < 30 ? "apparel" : cRoll < 55 ? "home" : cRoll < 78 ? "elec" : "outdoor";
72
+ const units = hash(i, 3) % 40;
73
+ out.push({
74
+ code: `SKU-${String(1000 + i)}`,
75
+ name: NAMES[hash(i, 4) % NAMES.length],
76
+ warehouse,
77
+ category,
78
+ units,
79
+ status: units === 0 ? "backorder" : units < 6 ? "low" : "in_stock",
80
+ });
81
+ }
82
+ return out;
83
+ })();
84
+
85
+ const whOf = (k: WarehouseKey) => WAREHOUSES.find((w) => w.key === k)!;
86
+ const catOf = (k: CategoryKey) => CATEGORIES.find((c) => c.key === k)!;
87
+
88
+ const STATUS: Record<Sku["status"], { label: string; color: "emerald" | "amber" | "red" }> = {
89
+ in_stock: { label: "In stock", color: "emerald" },
90
+ low: { label: "Low", color: "amber" },
91
+ backorder: { label: "Backorder", color: "red" },
92
+ };
93
+
94
+ const count = (n: number) => n.toLocaleString("en-US");
95
+
96
+ function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
97
+ return (
98
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
99
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
100
+ {children}
101
+ </View>
102
+ );
103
+ }
104
+
105
+ export function TplPivot() {
106
+ const [cell, setCell] = useState<MatrixCellRef | null>(null);
107
+ const [openIdx, setOpenIdx] = useState<number | null>(null);
108
+
109
+ // Cell value = how many SKUs sit at that warehouse × category.
110
+ const counts = useMemo(() => {
111
+ const m = new Map<string, number>();
112
+ for (const s of SKUS) m.set(`${s.warehouse}|${s.category}`, (m.get(`${s.warehouse}|${s.category}`) ?? 0) + 1);
113
+ return m;
114
+ }, []);
115
+ const value = (w: string, c: string) => counts.get(`${w}|${c}`) ?? 0;
116
+
117
+ // The drill register follows the pressed cell; no cell = the whole catalogue.
118
+ const items = useMemo(
119
+ () => (cell ? SKUS.filter((s) => s.warehouse === cell.row && s.category === cell.col) : SKUS),
120
+ [cell],
121
+ );
122
+ const open = openIdx !== null ? items[openIdx] : null;
123
+
124
+ const totalUnits = SKUS.reduce((s, x) => s + x.units, 0);
125
+ const lowOrOut = SKUS.filter((s) => s.status !== "in_stock").length;
126
+
127
+ return (
128
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
129
+ <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
130
+ <View style={{ gap: 2 }}>
131
+ <Text size="xl" weight="semibold">Stock distribution</Text>
132
+ <Text size="sm" color="muted">Where each category sits across the network — press a cell to list what's behind it</Text>
133
+ </View>
134
+
135
+ <KPIStrip
136
+ items={[
137
+ { label: "SKUs", value: SKUS.length, format: "number" },
138
+ { label: "Units on hand", value: totalUnits, format: "number" },
139
+ { label: "Low or backorder", value: lowOrOut, format: "number", tone: lowOrOut > 0 ? "warning" : "default", caption: "need attention" },
140
+ { label: "Warehouses", value: WAREHOUSES.length, format: "number" },
141
+ ]}
142
+ />
143
+
144
+ <Card style={{ padding: 0 }}>
145
+ <CardHeader>
146
+ <CardHeaderTitle info="Number of SKUs at each warehouse × category; the wash deepens with the count. Press a cell to filter the list below to that intersection.">
147
+ SKUs by warehouse × category
148
+ </CardHeaderTitle>
149
+ </CardHeader>
150
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
151
+ <Matrix
152
+ rows={WAREHOUSES.map((w) => ({ key: w.key, label: w.label }))}
153
+ cols={CATEGORIES.map((c) => ({ key: c.key, label: c.label }))}
154
+ value={value}
155
+ selected={cell}
156
+ onSelectCell={(c) => { setCell(c); setOpenIdx(null); }}
157
+ heatColor="indigo"
158
+ formatValue={count}
159
+ >
160
+ <Matrix.Header corner="Warehouse \ Category" totalLabel="Total" />
161
+ <Matrix.Grid display="both" />
162
+ <Matrix.Totals label="Total" />
163
+ <Matrix.Legend lessLabel="Fewer" moreLabel="More" />
164
+ </Matrix>
165
+ </View>
166
+ </Card>
167
+
168
+ <Card style={{ padding: 0 }}>
169
+ <CardHeader>
170
+ <CardHeaderTitle info="Items behind the selected cell. Press a row to open it.">
171
+ Items
172
+ </CardHeaderTitle>
173
+ {cell ? (
174
+ <Chip onDismiss={() => { setCell(null); setOpenIdx(null); }} dismissTooltip="Clear cell">
175
+ <Text size="xs" weight="medium" color="muted">{`${whOf(cell.row as WarehouseKey).label} · ${catOf(cell.col as CategoryKey).label}`}</Text>
176
+ </Chip>
177
+ ) : null}
178
+ </CardHeader>
179
+
180
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
181
+ <Text size="xs" color="muted" style={{ width: 96 }}>Code</Text>
182
+ <Text size="xs" color="muted" style={{ flex: 1 }}>Item</Text>
183
+ <Text size="xs" color="muted" align="right" style={{ width: 64 }}>Units</Text>
184
+ <Text size="xs" color="muted" style={{ width: 96 }}>Status</Text>
185
+ </View>
186
+ <Divider />
187
+
188
+ {items.length === 0 ? (
189
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
190
+ <EmptyState message="No items in this cell" hint="Press another cell or clear the filter" />
191
+ </View>
192
+ ) : (
193
+ items.slice(0, 10).map((s, i) => (
194
+ <View key={s.code}>
195
+ {i > 0 ? <Divider /> : null}
196
+ <PressableRow onPress={() => setOpenIdx(i)} selected={openIdx === i}>
197
+ <Pressable
198
+ accessibilityRole="button"
199
+ accessibilityLabel={`Open ${s.code}`}
200
+ onPress={() => setOpenIdx(i)}
201
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 44 }}
202
+ >
203
+ <Text size="sm" weight="medium" tabular style={{ width: 96 }} numberOfLines={1}>{s.code}</Text>
204
+ <Text size="sm" style={{ flex: 1 }} numberOfLines={1}>{s.name}</Text>
205
+ <Text size="sm" tabular align="right" style={{ width: 64 }}>{count(s.units)}</Text>
206
+ <View style={{ width: 96 }}>
207
+ <Badge variant="dot" label={STATUS[s.status].label} color={STATUS[s.status].color} />
208
+ </View>
209
+ </Pressable>
210
+ </PressableRow>
211
+ </View>
212
+ ))
213
+ )}
214
+
215
+ <CardFooter>
216
+ <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
217
+ {items.length > 10 ? `Showing 10 of ${count(items.length)}` : `${count(items.length)} item${items.length === 1 ? "" : "s"}`}
218
+ </Text>
219
+ </CardFooter>
220
+ </Card>
221
+ </View>
222
+
223
+ {open !== null && openIdx !== null ? (
224
+ <Drawer
225
+ open
226
+ onOpenChange={(o) => !o && setOpenIdx(null)}
227
+ title={open.code}
228
+ width={420}
229
+ onPrev={openIdx > 0 ? () => setOpenIdx(openIdx - 1) : undefined}
230
+ onNext={openIdx < Math.min(items.length, 10) - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
231
+ position={`${openIdx + 1}/${Math.min(items.length, 10)}`}
232
+ >
233
+ <View key={open.code} style={{ flex: 1, padding: 24, gap: 12 }}>
234
+ <Badge variant="dot" label={STATUS[open.status].label} color={STATUS[open.status].color} />
235
+ <View style={{ gap: 6, paddingTop: 4 }}>
236
+ <KVRow label="Item"><Text size="sm">{open.name}</Text></KVRow>
237
+ <Divider />
238
+ <KVRow label="Warehouse"><Text size="sm">{whOf(open.warehouse).label}</Text></KVRow>
239
+ <Divider />
240
+ <KVRow label="Category"><Text size="sm">{catOf(open.category).label}</Text></KVRow>
241
+ <Divider />
242
+ <KVRow label="Units on hand"><Text size="sm" tabular>{count(open.units)}</Text></KVRow>
243
+ </View>
244
+ </View>
245
+ </Drawer>
246
+ ) : null}
247
+ </ScrollView>
248
+ );
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.0.0",
3
+ "version": "7.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -55,6 +55,8 @@
55
55
  "./breakdown": "./src/breakdown.tsx",
56
56
  "./status_grid": "./src/status_grid.tsx",
57
57
  "./heatmap": "./src/heatmap.tsx",
58
+ "./matrix": "./src/matrix.tsx",
59
+ "./matrix_totals": "./src/matrix_totals.ts",
58
60
  "./trend_footer": "./src/trend_footer.tsx",
59
61
  "./locale": "./src/locale.tsx",
60
62
  "./spacing": "./src/spacing.ts",
@@ -5,7 +5,7 @@ import { colors } from "./colors";
5
5
  import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
6
6
  import { Icon } from "./icon";
7
7
  import { Button } from "./button";
8
- import { PressableHighlight } from "./pressable_highlight";
8
+ import { FocusRingPressable } from "./focus_ring_pressable";
9
9
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
10
10
  import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
11
11
  import { formatDate } from "./format_date";
@@ -120,8 +120,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
120
120
  return (
121
121
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
122
122
  <PopoverTrigger>
123
- <PressableHighlight
124
- focusRing
123
+ <FocusRingPressable
125
124
  testID={testID}
126
125
  accessibilityRole="button"
127
126
  accessibilityLabel={labels.selectDateRange}
@@ -143,7 +142,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
143
142
  {display}
144
143
  </Text>
145
144
  <Icon name="chevron-down" size={14} color={colors.zinc[400]} />
146
- </PressableHighlight>
145
+ </FocusRingPressable>
147
146
  </PopoverTrigger>
148
147
  <PopoverContent disableBodyScroll>
149
148
  <DateFilter
@@ -2,20 +2,37 @@ import { Pressable, type PressableProps, type StyleProp, type View, type ViewSty
2
2
  import { type Ref } from "react";
3
3
  import { FOCUS_RING } from "./control_surface";
4
4
  import { useFocusRing, composeHandler } from "./use_focus_ring";
5
+ import { type PressableHighlightState } from "./pressable_highlight";
5
6
 
6
- export interface FocusRingPressableProps extends PressableProps {
7
+ export interface FocusRingPressableProps extends Omit<PressableProps, "style"> {
7
8
  ref?: Ref<View>;
9
+ /**
10
+ * Narrows the state-function `style` to `PressableHighlightState`, so a control can
11
+ * read the web-only `hovered` (to paint its OWN border-hover) and `focusVisible`,
12
+ * exactly like `PressableHighlight` — the wash-free base still hands controls the
13
+ * flags they need to drive their edge.
14
+ */
15
+ style?: StyleProp<ViewStyle> | ((state: PressableHighlightState) => StyleProp<ViewStyle>);
16
+ /**
17
+ * `"none"` on a value/text surface (an inline-edit box) — a pressable is a button,
18
+ * not a selection surface, so a drag-click edits instead of starting a text
19
+ * selection. Exposed because RN types only carry `userSelect` on `TextStyle` while
20
+ * react-native-web applies it to any element.
21
+ */
22
+ userSelect?: "auto" | "none";
8
23
  }
9
24
 
10
25
  /**
11
26
  * A `Pressable` that paints the kit `FOCUS_RING` on keyboard focus — the raw-control
12
27
  * counterpart to `PressableHighlight`'s `focusRing` prop, for interactive elements
13
- * that DON'T want the hover wash (cells, tiles, nav buttons, menu options). Forwards
14
- * every `PressableProps` (onPress, accessibilityRole/Label/State, ref, …) and merges
15
- * the ring with the caller's `style` (object or state-function) + onFocus/onBlur.
16
- * Reach for this before hand-rolling a one-off Pressable + `useFocusRing`.
28
+ * that DON'T want the hover wash (the inputs/selects, cells, tiles, nav buttons, menu
29
+ * options). Forwards every `PressableProps` (onPress, accessibilityRole/Label/State,
30
+ * ref, …); its state-function `style` exposes `hovered`/`focusVisible` so a control
31
+ * paints its own border-hover, and it merges the focus ring + onFocus/onBlur. Reach
32
+ * for this for ANY interactive control whose hover affordance is its border (never a
33
+ * wash) — `PressableHighlight` is only for rows/menus/cards.
17
34
  */
18
- export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: FocusRingPressableProps) {
35
+ export function FocusRingPressable({ ref, style, onFocus, onBlur, userSelect, ...rest }: FocusRingPressableProps) {
19
36
  const { focusVisible, focusProps } = useFocusRing();
20
37
  return (
21
38
  <Pressable
@@ -25,7 +42,8 @@ export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: Foc
25
42
  onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
26
43
  style={(state) =>
27
44
  [
28
- typeof style === "function" ? style(state) : style,
45
+ userSelect ? ({ userSelect } as ViewStyle) : null,
46
+ typeof style === "function" ? style({ ...state, focusVisible }) : style,
29
47
  focusVisible ? { boxShadow: FOCUS_RING } : null,
30
48
  ] as StyleProp<ViewStyle>
31
49
  }
@@ -3,9 +3,9 @@ import { View, StyleSheet, type GestureResponderEvent } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { ActivityIndicator } from "./activity_indicator";
6
- import { PressableHighlight } from "./pressable_highlight";
6
+ import { FocusRingPressable } from "./focus_ring_pressable";
7
7
  import { colors } from "./colors";
8
- import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
8
+ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
9
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
10
10
 
11
11
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
@@ -124,7 +124,8 @@ interface InlineEditViewProps {
124
124
 
125
125
  /**
126
126
  * The view-mode box of an inline-editable field: the value as plain text in a
127
- * `PressableHighlight` (hover tints it; the pointer cursor signals it's
127
+ * `FocusRingPressable` (an input, so it hovers via its BORDER — the transparent
128
+ * edge reveals on hover, never a grey wash; the pointer cursor signals it's
128
129
  * editable), at the kit's control height with a transparent border — so
129
130
  * swapping to an input, or floating a dropdown above it, never shifts the
130
131
  * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
@@ -141,15 +142,17 @@ export function InlineEditView(props: InlineEditViewProps) {
141
142
  }
142
143
  : undefined;
143
144
  return (
144
- <PressableHighlight
145
- focusRing
145
+ <FocusRingPressable
146
146
  ref={ref}
147
147
  disabled={disabled}
148
148
  onPress={handlePress}
149
149
  accessibilityRole="button"
150
150
  accessibilityLabel={accessibilityLabel}
151
151
  userSelect="none"
152
- style={[styles.view, active && styles.viewActive]}
152
+ // An inline-edit field is an input — it hovers via its BORDER, not a grey
153
+ // wash: the transparent edge reveals on hover (the kit's hover-border),
154
+ // then fills + rings when open. Suppressed while open (the ring leads).
155
+ style={(state) => [styles.view, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
153
156
  >
154
157
  {typeof display === "string" ? (
155
158
  <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
@@ -159,7 +162,7 @@ export function InlineEditView(props: InlineEditViewProps) {
159
162
  <View style={styles.viewNode}>{display}</View>
160
163
  )}
161
164
  {trailing != null ? trailing : null}
162
- </PressableHighlight>
165
+ </FocusRingPressable>
163
166
  );
164
167
  }
165
168
 
@@ -250,6 +253,11 @@ const styles = StyleSheet.create({
250
253
  borderColor: colors.border,
251
254
  boxShadow: FOCUS_RING,
252
255
  },
256
+ // Hover (not open): reveal the kit hover-border on the transparent edge — the
257
+ // input-family affordance, no grey wash.
258
+ viewHovered: {
259
+ borderColor: HOVER_BORDER,
260
+ },
253
261
  placeholder: { color: colors.zinc[400] },
254
262
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
255
263
  viewNode: { flex: 1, minWidth: 0 },
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { matrixTotals, type MatrixAxisItem } from "./matrix_totals";
3
+
4
+ const rows: MatrixAxisItem[] = [
5
+ { key: "msc", label: "MSC" },
6
+ { key: "one", label: "ONE" },
7
+ ];
8
+ const cols: MatrixAxisItem[] = [
9
+ { key: "20", label: "20ft" },
10
+ { key: "40", label: "40ft" },
11
+ ];
12
+
13
+ // MSC: 20→5, 40→3 ; ONE: 20→0, 40→7
14
+ const grid: Record<string, Record<string, number>> = {
15
+ msc: { "20": 5, "40": 3 },
16
+ one: { "20": 0, "40": 7 },
17
+ };
18
+ const value = (r: string, c: string) => grid[r]?.[c] ?? 0;
19
+
20
+ describe("matrixTotals", () => {
21
+ it("sums each row across its columns", () => {
22
+ const { rowTotals } = matrixTotals(rows, cols, value);
23
+ expect(rowTotals.get("msc")).toBe(8);
24
+ expect(rowTotals.get("one")).toBe(7);
25
+ });
26
+
27
+ it("sums each column across its rows", () => {
28
+ const { colTotals } = matrixTotals(rows, cols, value);
29
+ expect(colTotals.get("20")).toBe(5);
30
+ expect(colTotals.get("40")).toBe(10);
31
+ });
32
+
33
+ it("grand total equals the sum of every cell", () => {
34
+ expect(matrixTotals(rows, cols, value).grandTotal).toBe(15);
35
+ });
36
+
37
+ it("max is the largest single cell, for the heat scale", () => {
38
+ expect(matrixTotals(rows, cols, value).max).toBe(7);
39
+ });
40
+
41
+ it("is all-zero for an empty axis", () => {
42
+ const r = matrixTotals([], cols, value);
43
+ expect(r.grandTotal).toBe(0);
44
+ expect(r.max).toBe(0);
45
+ expect(r.colTotals.get("20")).toBeUndefined();
46
+ });
47
+
48
+ it("counts a fully-empty grid as zero without dividing by it", () => {
49
+ const zero = matrixTotals(rows, cols, () => 0);
50
+ expect(zero.grandTotal).toBe(0);
51
+ expect(zero.max).toBe(0);
52
+ expect(zero.rowTotals.get("msc")).toBe(0);
53
+ });
54
+ });
package/src/matrix.tsx ADDED
@@ -0,0 +1,347 @@
1
+ import { Children, createContext, isValidElement, useContext, useMemo, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, withAlpha, type ColorName } from "./colors";
4
+ import { Text } from "./text";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
6
+ import { matrixTotals, type MatrixAxisItem, type MatrixCellRef } from "./matrix_totals";
7
+
8
+ type Display = "number" | "heat" | "both";
9
+
10
+ interface MatrixContextValue {
11
+ rows: MatrixAxisItem[];
12
+ cols: MatrixAxisItem[];
13
+ value: (row: string, col: string) => number;
14
+ max: number;
15
+ rowTotals: Map<string, number>;
16
+ colTotals: Map<string, number>;
17
+ grandTotal: number;
18
+ hasTotals: boolean;
19
+ selected: MatrixCellRef | null;
20
+ onSelectCell?: (cell: MatrixCellRef | null) => void;
21
+ heatBase: string;
22
+ formatValue: (n: number) => string;
23
+ rowLabelWidth: number;
24
+ totalColWidth: number;
25
+ }
26
+
27
+ const MatrixContext = createContext<MatrixContextValue | null>(null);
28
+
29
+ function useMatrix(): MatrixContextValue {
30
+ const ctx = useContext(MatrixContext);
31
+ if (!ctx) throw new Error("Matrix.* must be rendered inside <Matrix>");
32
+ return ctx;
33
+ }
34
+
35
+ const defaultFormat = (n: number): string => n.toLocaleString("en-US");
36
+
37
+ export interface MatrixProps {
38
+ /** Row dimension (e.g. one shipping line per row). The consumer pre-sorts. */
39
+ rows: MatrixAxisItem[];
40
+ /** Column dimension (e.g. container size). Few, fixed — they share the width. */
41
+ cols: MatrixAxisItem[];
42
+ /** The cell value at an intersection. Pure — keep it a Map lookup, not a scan. */
43
+ value: (row: string, col: string) => number;
44
+ /** The inspected cell. Press the same cell again to clear. */
45
+ selected?: MatrixCellRef | null;
46
+ /** Omit for an informational matrix (no drill-down). */
47
+ onSelectCell?: (cell: MatrixCellRef | null) => void;
48
+ /** Heat hue. Intensity is alpha within it; zero cells go neutral. Default blue. */
49
+ heatColor?: ColorName;
50
+ formatValue?: (n: number) => string;
51
+ /** Width of the leading row-label column. Default 140 (room for long names). */
52
+ rowLabelWidth?: number;
53
+ children: ReactNode;
54
+ }
55
+
56
+ /**
57
+ * A two-dimensional cross-tab — the pivot of one dimension against another
58
+ * ("how many of each line × size?"). Band-compound: the root owns the data,
59
+ * the heat scale, the totals and the cell-selection; you compose the opt-in
60
+ * bands — `Matrix.Header` (axis labels + corner), `Matrix.Grid` (the cells:
61
+ * a number, a heat wash, or both), `Matrix.Totals` (row + column + grand),
62
+ * `Matrix.Legend` (the less→more scale). Pressing a cell selects it for the
63
+ * host to drill (a filtered list underneath) — every number is a door. Omit a
64
+ * band for a different combination: `<Grid display="heat"/>` + `<Legend/>` is a
65
+ * density heatmap; add `<Totals/>` and `display="both"` for a full pivot table.
66
+ */
67
+ export function Matrix(props: MatrixProps) {
68
+ const {
69
+ rows,
70
+ cols,
71
+ value,
72
+ selected = null,
73
+ onSelectCell,
74
+ heatColor = "blue",
75
+ formatValue = defaultFormat,
76
+ rowLabelWidth = 140,
77
+ children,
78
+ } = props;
79
+
80
+ // Marker, not reference identity — a bundler that loads two copies of this
81
+ // module (or a HMR swap) would break `c.type === MatrixTotals`; the static
82
+ // flag travels with whichever function the element points at.
83
+ const hasTotals = useMemo(
84
+ () => Children.toArray(children).some((c) => isValidElement(c) && typeof c.type === "function" && "isMatrixTotals" in c.type),
85
+ [children],
86
+ );
87
+
88
+ const { max, rowTotals, colTotals, grandTotal } = useMemo(
89
+ () => matrixTotals(rows, cols, value),
90
+ [rows, cols, value],
91
+ );
92
+
93
+ const ctx: MatrixContextValue = {
94
+ rows,
95
+ cols,
96
+ value,
97
+ max: Math.max(1, max),
98
+ rowTotals,
99
+ colTotals,
100
+ grandTotal,
101
+ hasTotals,
102
+ selected,
103
+ onSelectCell,
104
+ heatBase: colors[heatColor][600],
105
+ formatValue,
106
+ rowLabelWidth,
107
+ totalColWidth: 64,
108
+ };
109
+
110
+ return (
111
+ <MatrixContext.Provider value={ctx}>
112
+ <View style={styles.container}>{children}</View>
113
+ </MatrixContext.Provider>
114
+ );
115
+ }
116
+
117
+ export interface MatrixHeaderProps {
118
+ /** Top-left corner label — name the two axes, e.g. "Line \ Size". */
119
+ corner?: ReactNode;
120
+ /** Header for the trailing total column (only shown when `Matrix.Totals` is present). */
121
+ totalLabel?: string;
122
+ }
123
+
124
+ function MatrixHeader({ corner, totalLabel = "Total" }: MatrixHeaderProps) {
125
+ const { cols, rowLabelWidth, totalColWidth, hasTotals } = useMatrix();
126
+ return (
127
+ <View style={styles.headRow}>
128
+ <View style={[styles.rowLabel, { width: rowLabelWidth }]}>
129
+ {typeof corner === "string" ? (
130
+ <Text size="xs" color="muted" numberOfLines={1}>
131
+ {corner}
132
+ </Text>
133
+ ) : (
134
+ corner
135
+ )}
136
+ </View>
137
+ {cols.map((c) => (
138
+ <Text key={c.key} size="xs" color="muted" align="center" numberOfLines={1} style={styles.colCell}>
139
+ {c.label}
140
+ </Text>
141
+ ))}
142
+ {hasTotals ? (
143
+ <Text size="xs" color="muted" weight="medium" align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
144
+ {totalLabel}
145
+ </Text>
146
+ ) : null}
147
+ </View>
148
+ );
149
+ }
150
+
151
+ export interface MatrixGridProps {
152
+ /** What each cell shows. `both` = a heat wash behind the number. Default `both`. */
153
+ display?: Display;
154
+ }
155
+
156
+ function MatrixGrid({ display = "both" }: MatrixGridProps) {
157
+ const ctx = useMatrix();
158
+ const { rows, cols, value, hasTotals, rowTotals, formatValue, rowLabelWidth, totalColWidth } = ctx;
159
+ return (
160
+ <View style={styles.grid}>
161
+ {rows.map((r) => (
162
+ <View key={r.key} style={styles.row}>
163
+ <Text size="sm" color="muted" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
164
+ {r.label}
165
+ </Text>
166
+ {cols.map((c) => (
167
+ <MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
168
+ ))}
169
+ {hasTotals ? (
170
+ <Text size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
171
+ {formatValue(rowTotals.get(r.key) ?? 0)}
172
+ </Text>
173
+ ) : null}
174
+ </View>
175
+ ))}
176
+ </View>
177
+ );
178
+ }
179
+
180
+ interface MatrixCellProps {
181
+ ctx: MatrixContextValue;
182
+ display: Display;
183
+ row: MatrixAxisItem;
184
+ col: MatrixAxisItem;
185
+ value: number;
186
+ }
187
+
188
+ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
189
+ const { max, selected, onSelectCell, heatBase, formatValue } = ctx;
190
+ const showHeat = display === "heat" || display === "both";
191
+ const showNumber = display === "number" || display === "both";
192
+
193
+ // A gentler wash behind a number (keeps it legible); the full ramp when the
194
+ // colour IS the value (a pure heatmap).
195
+ const background = showHeat
196
+ ? value === 0
197
+ ? colors.zinc[100]
198
+ : withAlpha(heatBase, display === "both" ? 0.05 + 0.4 * (value / max) : 0.15 + 0.85 * (value / max))
199
+ : colors.background;
200
+
201
+ const isSelected = selected?.row === row.key && selected?.col === col.key;
202
+ const pressable = !!onSelectCell && value > 0;
203
+ const label = `${row.label} · ${col.label}: ${formatValue(value)}`;
204
+
205
+ const content = showNumber ? (
206
+ <Text size="sm" weight="medium" tabular align="center" color={value === 0 ? "zinc-500" : "default"}>
207
+ {value === 0 ? "" : formatValue(value)}
208
+ </Text>
209
+ ) : null;
210
+
211
+ if (!pressable) {
212
+ return (
213
+ <View style={[styles.cell, { backgroundColor: background, borderColor: "transparent" }]} accessibilityLabel={showNumber ? undefined : label}>
214
+ {content}
215
+ </View>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <FocusRingPressable
221
+ accessibilityRole="button"
222
+ accessibilityState={{ selected: isSelected }}
223
+ accessibilityLabel={label}
224
+ onPress={() => onSelectCell?.(isSelected ? null : { row: row.key, col: col.key })}
225
+ style={(state) => [
226
+ styles.cell,
227
+ {
228
+ backgroundColor: background,
229
+ borderColor: isSelected ? colors.zinc[900] : state.hovered ? colors.zinc[400] : "transparent",
230
+ },
231
+ ]}
232
+ >
233
+ {content}
234
+ </FocusRingPressable>
235
+ );
236
+ }
237
+
238
+ export interface MatrixTotalsProps {
239
+ /** The row label for the totals band, e.g. "Total". */
240
+ label?: string;
241
+ }
242
+
243
+ function MatrixTotals({ label = "Total" }: MatrixTotalsProps) {
244
+ const { cols, colTotals, grandTotal, formatValue, rowLabelWidth, totalColWidth } = useMatrix();
245
+ return (
246
+ <View style={styles.totalsRow}>
247
+ <Text size="sm" weight="semibold" color="muted" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
248
+ {label}
249
+ </Text>
250
+ {cols.map((c) => (
251
+ <Text key={c.key} size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={styles.colCell}>
252
+ {formatValue(colTotals.get(c.key) ?? 0)}
253
+ </Text>
254
+ ))}
255
+ <Text size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
256
+ {formatValue(grandTotal)}
257
+ </Text>
258
+ </View>
259
+ );
260
+ }
261
+
262
+ export interface MatrixLegendProps {
263
+ lessLabel?: string;
264
+ moreLabel?: string;
265
+ }
266
+
267
+ function MatrixLegend({ lessLabel = "Less", moreLabel = "More" }: MatrixLegendProps) {
268
+ const { heatBase } = useMatrix();
269
+ return (
270
+ <View style={styles.scale}>
271
+ <Text size="xs" color="muted">
272
+ {lessLabel}
273
+ </Text>
274
+ {[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
275
+ <View key={alpha} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
276
+ ))}
277
+ <Text size="xs" color="muted">
278
+ {moreLabel}
279
+ </Text>
280
+ </View>
281
+ );
282
+ }
283
+
284
+ Matrix.Header = MatrixHeader;
285
+ Matrix.Grid = MatrixGrid;
286
+ Matrix.Totals = Object.assign(MatrixTotals, { isMatrixTotals: true as const });
287
+ Matrix.Legend = MatrixLegend;
288
+
289
+ const styles = StyleSheet.create({
290
+ container: {
291
+ gap: 3,
292
+ },
293
+ headRow: {
294
+ flexDirection: "row",
295
+ alignItems: "center",
296
+ gap: 3,
297
+ paddingBottom: 2,
298
+ },
299
+ grid: {
300
+ gap: 3,
301
+ },
302
+ row: {
303
+ flexDirection: "row",
304
+ alignItems: "center",
305
+ gap: 3,
306
+ },
307
+ rowLabel: {
308
+ paddingRight: 8,
309
+ },
310
+ cell: {
311
+ flex: 1,
312
+ minWidth: 52,
313
+ height: 36,
314
+ borderRadius: 4,
315
+ borderWidth: 2,
316
+ alignItems: "center",
317
+ justifyContent: "center",
318
+ },
319
+ colCell: {
320
+ flex: 1,
321
+ minWidth: 52,
322
+ },
323
+ totalCol: {
324
+ paddingLeft: 4,
325
+ },
326
+ totalsRow: {
327
+ flexDirection: "row",
328
+ alignItems: "center",
329
+ gap: 3,
330
+ marginTop: 3,
331
+ paddingTop: 8,
332
+ borderTopWidth: 1,
333
+ borderTopColor: colors.border,
334
+ },
335
+ scale: {
336
+ flexDirection: "row",
337
+ alignItems: "center",
338
+ justifyContent: "flex-end",
339
+ gap: 4,
340
+ paddingTop: 8,
341
+ },
342
+ swatch: {
343
+ width: 14,
344
+ height: 10,
345
+ borderRadius: 3,
346
+ },
347
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The data layer for `Matrix` — axis types + the cross-tab aggregation. Kept
3
+ * free of any React-Native import so it unit-tests directly (the component in
4
+ * `matrix.tsx` renders these numbers; a consumer can drive a KPI off the same
5
+ * `matrixTotals` it shows in the grid).
6
+ */
7
+
8
+ export interface MatrixAxisItem {
9
+ key: string;
10
+ label: string;
11
+ }
12
+
13
+ /** The inspected cell — host renders its detail (a filtered list) underneath. */
14
+ export interface MatrixCellRef {
15
+ row: string;
16
+ col: string;
17
+ }
18
+
19
+ export interface MatrixTotalsResult {
20
+ rowTotals: Map<string, number>;
21
+ colTotals: Map<string, number>;
22
+ grandTotal: number;
23
+ /** The largest single cell — the heat scale's upper bound. */
24
+ max: number;
25
+ }
26
+
27
+ /**
28
+ * Row sums, column sums, grand total, and the max single cell — computed in one
29
+ * pass. The single source of truth for the matrix's totals.
30
+ */
31
+ export function matrixTotals(
32
+ rows: MatrixAxisItem[],
33
+ cols: MatrixAxisItem[],
34
+ value: (row: string, col: string) => number,
35
+ ): MatrixTotalsResult {
36
+ const rowTotals = new Map<string, number>();
37
+ const colTotals = new Map<string, number>();
38
+ let max = 0;
39
+ let grandTotal = 0;
40
+ for (const r of rows) {
41
+ let rowSum = 0;
42
+ for (const c of cols) {
43
+ const v = value(r.key, c.key);
44
+ rowSum += v;
45
+ colTotals.set(c.key, (colTotals.get(c.key) ?? 0) + v);
46
+ if (v > max) max = v;
47
+ }
48
+ rowTotals.set(r.key, rowSum);
49
+ grandTotal += rowSum;
50
+ }
51
+ return { rowTotals, colTotals, grandTotal, max };
52
+ }
package/src/select.tsx CHANGED
@@ -2,7 +2,7 @@ import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type View
2
2
  import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
3
3
  import { colors } from "./colors";
4
4
  import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
5
- import { PressableHighlight } from "./pressable_highlight";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
6
6
  import { Text } from "./text";
7
7
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
8
8
  import { OptionList } from "./option_list";
@@ -217,13 +217,13 @@ function SelectTrigger<T extends string>({
217
217
  const hasSelection = selectedItems.length > 0;
218
218
 
219
219
  return (
220
- // PressableHighlight owns the hover wash + keyboard focus ring (layered AROUND
221
- // the caller's `style`, so a borderless override — `borderColor:"transparent"`
222
- // for an in-cell Select keeps the wash and reads like its inline neighbours).
223
- // The hover-border sits BEFORE `style` so a transparent border suppresses it
224
- // (wash only), while a bordered Select darkens its edge.
225
- <PressableHighlight
226
- focusRing
220
+ // The wash-free control base (FOCUS_RING + a11y, NO grey wash) — a Select is an
221
+ // input, so its hover affordance is its BORDER, never a content wash. The
222
+ // hover-border sits AFTER `style` so it wins over the caller's resting border:
223
+ // a bordered Select darkens its edge, and a borderless in-cell Select
224
+ // (`borderColor:"transparent"`) REVEALS one both reading as "interactive",
225
+ // neither greying its content.
226
+ <FocusRingPressable
227
227
  ref={ref}
228
228
  testID={testID}
229
229
  // Without a role this Pressable renders as an unfocusable <div> on web — the
@@ -233,7 +233,7 @@ function SelectTrigger<T extends string>({
233
233
  accessibilityRole="button"
234
234
  accessibilityLabel={accessibilityLabel}
235
235
  accessibilityState={{ expanded: open, disabled }}
236
- style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, state.hovered && !disabled && { borderColor: HOVER_BORDER }, style]}
236
+ style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, style, state.hovered && !disabled && { borderColor: HOVER_BORDER }]}
237
237
  onPress={!disabled ? onPress : undefined}
238
238
  disabled={disabled}
239
239
  >
@@ -267,7 +267,7 @@ function SelectTrigger<T extends string>({
267
267
  {placeholder}
268
268
  </Text>
269
269
  )}
270
- </PressableHighlight>
270
+ </FocusRingPressable>
271
271
  );
272
272
  }
273
273