@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 +32 -16
- package/examples/tpl_pivot.tsx +249 -0
- package/package.json +3 -1
- package/src/date_range_filter_field.tsx +3 -4
- package/src/focus_ring_pressable.tsx +25 -7
- package/src/inline_edit.tsx +15 -7
- package/src/matrix.test.ts +54 -0
- package/src/matrix.tsx +347 -0
- package/src/matrix_totals.ts +52 -0
- package/src/select.tsx +10 -10
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
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
- **
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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) · `
|
|
704
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
14
|
-
* every `PressableProps` (onPress, accessibilityRole/Label/State,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
* `
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// (
|
|
225
|
-
|
|
226
|
-
|
|
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 }
|
|
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
|
-
</
|
|
270
|
+
</FocusRingPressable>
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
273
|
|