@lotics/ui 7.1.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`.
@@ -713,8 +715,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
713
715
  - **Monitor & decide** — `tpl_dashboard` (KPIStrip + trends + attention list) · `tpl_stock`
714
716
  (10k-record faceted funnel) · `tpl_report` (scope-first lookup report — a header period +
715
717
  one-of-N dimension search drives KPIs → aligned facets w/ a `maxRows` long-tail toggle →
716
- paginated register → export) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_rollup`
717
- (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).
718
721
  - **Work execution** — `app_orders` (stage-gated work queue) · `tpl_crm_desk` (assign & work +
719
722
  `FloatingActionBar`) · `tpl_callsheet` (call console) · `tpl_convert` (staged conversion) ·
720
723
  `tpl_inventory` (threshold actions) · `tpl_dossier` (high-volume paginated register) ·
@@ -798,7 +801,7 @@ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable
798
801
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
799
802
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
800
803
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
801
- 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 ·
802
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 ·
803
806
  file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
804
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.1.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",
@@ -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
+ }