@lotics/ui 7.8.0 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +71 -32
- package/examples/tpl_allocate.tsx +6 -6
- package/examples/tpl_assistant.tsx +1 -1
- package/examples/tpl_attendance.tsx +1 -1
- package/examples/tpl_billing.tsx +2 -2
- package/examples/tpl_briefing.tsx +1 -1
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_compare.tsx +1 -1
- package/examples/tpl_crosscheck.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_detail.tsx +1 -1
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_directory.tsx +5 -8
- package/examples/tpl_draft.tsx +2 -2
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_intake.tsx +1 -1
- package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
- package/examples/tpl_lookup.tsx +2 -2
- package/examples/tpl_match.tsx +2 -2
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pick.tsx +64 -68
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_quick.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_record_plain.tsx +1 -1
- package/examples/tpl_report.tsx +1 -1
- package/examples/tpl_rollup.tsx +1 -1
- package/examples/tpl_settings.tsx +1 -1
- package/examples/tpl_shifts.tsx +1 -1
- package/examples/tpl_stock.tsx +1 -1
- package/examples/tpl_task_board.tsx +2 -2
- package/examples/tpl_tasks.tsx +1 -1
- package/examples/tpl_timeline.tsx +1 -1
- package/examples/tpl_tower.tsx +1 -1
- package/examples/tpl_triage.tsx +1 -1
- package/examples/tpl_wizard.tsx +1 -1
- package/package.json +3 -1
- package/src/card.tsx +6 -13
- package/src/data_grid.tsx +1 -1
- package/src/kpi_strip.tsx +5 -5
- package/src/pressable_row.tsx +22 -10
- package/src/sort_header.tsx +9 -4
- package/src/summary_line.tsx +76 -0
- package/src/table.tsx +27 -11
- package/src/use_selection.test.ts +66 -0
- package/src/use_selection.ts +75 -0
- package/examples/app_orders.tsx +0 -405
- package/examples/tpl_approvals.tsx +0 -375
- package/examples/tpl_batch.tsx +0 -234
- package/examples/tpl_callsheet.tsx +0 -481
- package/examples/tpl_convert.tsx +0 -478
- package/examples/tpl_crm_desk.tsx +0 -541
- package/examples/tpl_dispatch.tsx +0 -289
- package/examples/tpl_inventory.tsx +0 -299
- package/examples/tpl_pipeline.tsx +0 -400
- package/examples/tpl_reconcile.tsx +0 -275
- package/examples/tpl_run.tsx +0 -235
|
@@ -83,7 +83,7 @@ export function TplRecordPlain() {
|
|
|
83
83
|
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
84
84
|
|
|
85
85
|
return (
|
|
86
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
86
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
87
87
|
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
|
|
88
88
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
89
89
|
<Text size="xl" weight="semibold">
|
package/examples/tpl_report.tsx
CHANGED
|
@@ -217,7 +217,7 @@ export function TplReport() {
|
|
|
217
217
|
];
|
|
218
218
|
|
|
219
219
|
return (
|
|
220
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
220
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
221
221
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
222
222
|
{/* header band — title + the persistent period + the one primary action */}
|
|
223
223
|
<View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap", zIndex: 20 }}>
|
package/examples/tpl_rollup.tsx
CHANGED
|
@@ -143,7 +143,7 @@ export function TplRollup() {
|
|
|
143
143
|
const openLane = open ? open.site.lanes[open.idx] : null;
|
|
144
144
|
|
|
145
145
|
return (
|
|
146
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
146
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
147
147
|
<View style={{ width: "100%", maxWidth: 980, alignSelf: "center", gap: 16 }}>
|
|
148
148
|
{/* header band */}
|
|
149
149
|
<View style={{ gap: 2 }}>
|
|
@@ -89,7 +89,7 @@ export function TplSettings() {
|
|
|
89
89
|
|
|
90
90
|
return (
|
|
91
91
|
<ScrollView
|
|
92
|
-
style={{ flex: 1, backgroundColor: colors.
|
|
92
|
+
style={{ flex: 1, backgroundColor: colors.white }}
|
|
93
93
|
contentContainerStyle={{ padding: 28 }}
|
|
94
94
|
>
|
|
95
95
|
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
package/examples/tpl_shifts.tsx
CHANGED
|
@@ -181,7 +181,7 @@ export function TplShifts() {
|
|
|
181
181
|
filled >= req ? solid("emerald") : filled === 0 ? solid("red") : solid("amber");
|
|
182
182
|
|
|
183
183
|
return (
|
|
184
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
184
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
185
185
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
186
186
|
{/* header band */}
|
|
187
187
|
<View style={{ gap: 2 }}>
|
package/examples/tpl_stock.tsx
CHANGED
|
@@ -188,7 +188,7 @@ export function TplStock() {
|
|
|
188
188
|
];
|
|
189
189
|
|
|
190
190
|
return (
|
|
191
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
191
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
192
192
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
193
193
|
{/* header band */}
|
|
194
194
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
@@ -40,7 +40,7 @@ type TagOption = { value: string; label: string };
|
|
|
40
40
|
// Template · Task board — the manager's view, built on the `DataGrid` primitive
|
|
41
41
|
// (the inline-managed grouped table). The board OWNS its data, state, toolbar,
|
|
42
42
|
// columns and per-group add; `DataGrid` owns the sortable header, the collapsible
|
|
43
|
-
// grouped sections and the aligned rows.
|
|
43
|
+
// grouped sections and the aligned rows.
|
|
44
44
|
// Moderate data only — for 10k+ records you browse, use the paginated register.
|
|
45
45
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
46
|
|
|
@@ -552,6 +552,6 @@ const styles = StyleSheet.create({
|
|
|
552
552
|
result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
|
|
553
553
|
// Fills the cell (height + width) so the link's hit area matches the inline cells.
|
|
554
554
|
action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
|
|
555
|
-
actionHover: { backgroundColor: colors.
|
|
555
|
+
actionHover: { backgroundColor: colors.white },
|
|
556
556
|
source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
557
557
|
});
|
package/examples/tpl_tasks.tsx
CHANGED
|
@@ -426,7 +426,7 @@ const styles = StyleSheet.create({
|
|
|
426
426
|
paddingHorizontal: 8,
|
|
427
427
|
borderRadius: 10,
|
|
428
428
|
},
|
|
429
|
-
rowHover: { backgroundColor: colors.
|
|
429
|
+
rowHover: { backgroundColor: colors.white },
|
|
430
430
|
// The expand affordance — its own button (meta + chevron), sibling to the ring
|
|
431
431
|
// and title so no interactive control nests inside another.
|
|
432
432
|
expand: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8 },
|
|
@@ -206,7 +206,7 @@ export function TplTimeline() {
|
|
|
206
206
|
.filter((d) => d.events.length > 0);
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
209
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
210
210
|
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
211
211
|
{/* header band */}
|
|
212
212
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
package/examples/tpl_tower.tsx
CHANGED
|
@@ -162,7 +162,7 @@ export function TplTower() {
|
|
|
162
162
|
: 0;
|
|
163
163
|
|
|
164
164
|
return (
|
|
165
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
165
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
166
166
|
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
167
167
|
{/* header band */}
|
|
168
168
|
<View style={{ gap: 2 }}>
|
package/examples/tpl_triage.tsx
CHANGED
|
@@ -54,7 +54,7 @@ export function TplTriage() {
|
|
|
54
54
|
const review = open.filter((r) => r.confidence !== "high").length;
|
|
55
55
|
|
|
56
56
|
return (
|
|
57
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
57
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
58
58
|
<View style={{ width: "100%", maxWidth: 820, alignSelf: "center", gap: 16 }}>
|
|
59
59
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
60
60
|
<View style={{ flex: 1, gap: 2 }}>
|
package/examples/tpl_wizard.tsx
CHANGED
|
@@ -86,7 +86,7 @@ export function TplWizard() {
|
|
|
86
86
|
const goEdit = (s: number) => setStep(s);
|
|
87
87
|
|
|
88
88
|
return (
|
|
89
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.
|
|
89
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
90
90
|
<View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
|
|
91
91
|
<View style={{ gap: 2 }}>
|
|
92
92
|
<Text size="xl" weight="semibold">New partner application</Text>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./image_gallery": "./src/image_gallery.tsx",
|
|
30
30
|
"./rotatable_image": "./src/rotatable_image.tsx",
|
|
31
31
|
"./use_image_rotation": "./src/use_image_rotation.ts",
|
|
32
|
+
"./use_selection": "./src/use_selection.ts",
|
|
32
33
|
"./use_selection_mode": "./src/use_selection_mode.ts",
|
|
33
34
|
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
34
35
|
"./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"./section_card": "./src/section_card.tsx",
|
|
45
46
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
46
47
|
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
48
|
+
"./summary_line": "./src/summary_line.tsx",
|
|
47
49
|
"./empty_state": "./src/empty_state.tsx",
|
|
48
50
|
"./format_date": "./src/format_date.ts",
|
|
49
51
|
"./format_money": "./src/format_money.ts",
|
package/src/card.tsx
CHANGED
|
@@ -16,6 +16,10 @@ interface CardProps {
|
|
|
16
16
|
* for a pressable/selectable card use CardSelectItem (a real focusable button),
|
|
17
17
|
* or compose PressableHighlight for a bespoke action.
|
|
18
18
|
*
|
|
19
|
+
* A Card is ALWAYS a card — a bordered, lifted container that groups and
|
|
20
|
+
* separates. A work-execution register that wants a flat look uses NO Card (the
|
|
21
|
+
* register sits directly on the page); there is no "flat card".
|
|
22
|
+
*
|
|
19
23
|
* Two shapes:
|
|
20
24
|
* - Plain: `<Card>` with the default 20px padding, author owns the content.
|
|
21
25
|
* - Banded: `<Card style={{ padding: 0 }}>` composed from the family slots —
|
|
@@ -140,26 +144,15 @@ export function CardFooter(props: CardFooterProps) {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
const styles = StyleSheet.create({
|
|
147
|
+
// The bordered, lifted card — border anchors, shadow lifts; the shadow uses a
|
|
148
|
+
// warm-neutral tint (38,38,38), not pure black (premium signal).
|
|
143
149
|
container: {
|
|
144
150
|
padding: 20,
|
|
145
151
|
borderRadius: 16,
|
|
146
|
-
// Clip banded content (full-bleed rows, their hover/selected wash) to the
|
|
147
|
-
// rounded corners. The CSS box-shadow below is painted outside the border
|
|
148
|
-
// box, so `overflow: hidden` on the same element doesn't clip it — the lift
|
|
149
|
-
// survives. In-card overlays (Peek/ActionMenu/Popover/Tooltip) are
|
|
150
|
-
// portaled, so they aren't clipped either.
|
|
151
152
|
overflow: "hidden",
|
|
152
153
|
backgroundColor: colors.background,
|
|
153
|
-
// 1px hairline + 2-layer shadow: the research-validated default across
|
|
154
|
-
// shadcn/ui, Geist, Tailwind v4. Without either, cards on a near-white
|
|
155
|
-
// page bg float without rhythm. Border anchors; shadow lifts. The shadow
|
|
156
|
-
// uses warm-neutral tint (38,38,38), not pure black — premium signal vs
|
|
157
|
-
// cheap-feeling pure-black shadows.
|
|
158
154
|
borderWidth: 1,
|
|
159
155
|
borderColor: colors.zinc["200"],
|
|
160
|
-
// Slightly more visible than shadcn's `shadow-sm` — chosen because our
|
|
161
|
-
// page bg is `colors.background` (off-white), making `shadow-sm` nearly
|
|
162
|
-
// invisible. Stripe / Linear use this stronger value on similar bg.
|
|
163
156
|
...({
|
|
164
157
|
boxShadow:
|
|
165
158
|
"0 1px 2px 0 rgba(38,38,38,0.06), 0 4px 12px -2px rgba(38,38,38,0.06)",
|
package/src/data_grid.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export interface DataGridProps<T> {
|
|
|
52
52
|
* (search · group-by · filters), the per-group add (`renderGroupFooter`), and every
|
|
53
53
|
* CELL (`columns[].cell` renders ANY field). It renders ALL rows — no pagination /
|
|
54
54
|
* virtualization — so it's for hundreds, low-thousands; at 10k+ use the register.
|
|
55
|
-
* Worked
|
|
55
|
+
* Worked example: `tpl_task_board`.
|
|
56
56
|
*/
|
|
57
57
|
export function DataGrid<T>(props: DataGridProps<T>) {
|
|
58
58
|
const { columns, groups, getRowKey, leading, sort, onSort, labels, collapsed, onToggleCollapse, renderGroupFooter } = props;
|
package/src/kpi_strip.tsx
CHANGED
|
@@ -34,11 +34,11 @@ export interface KPIStripProps {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* THE stat band
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* screens (columns keep a 180px floor).
|
|
37
|
+
* THE dashboard stat band — one Card, hairline-divided columns of KPICard with a
|
|
38
|
+
* uniform value scale, at the TOP of a DASHBOARD page. It is NOT for register /
|
|
39
|
+
* list pages: a list summarizes the FILTERED view with the light inline
|
|
40
|
+
* `SummaryLine` below its toolbar, never this boxed metric grid. Screens must
|
|
41
|
+
* not hand-compose stat bands. Wraps on narrow screens (columns keep a 180px floor).
|
|
42
42
|
*/
|
|
43
43
|
export function KPIStrip(props: KPIStripProps) {
|
|
44
44
|
const { items, size = "lg" } = props;
|
package/src/pressable_row.tsx
CHANGED
|
@@ -11,16 +11,19 @@ export interface PressableRowProps {
|
|
|
11
11
|
* it's the resting state of a ticked row in a bulk-select register. */
|
|
12
12
|
marked?: boolean;
|
|
13
13
|
/**
|
|
14
|
-
* - "
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - "register" (THE record-list default): a rounded row whose hover/open/`marked`
|
|
15
|
+
* wash spans the FULL row width (incl. nested controls); content stays on the
|
|
16
|
+
* 20px column gutter so a `Table` header and its cells still align. The `Table`
|
|
17
|
+
* separates rows with a `Divider` (the wash is the hover/selection state, the
|
|
18
|
+
* hairline the resting separation) — every app inherits this register look.
|
|
19
|
+
* - "bleed" (legacy): px-20, square wash to the edges, `Divider`-separated.
|
|
20
|
+
* The pre-rounded register row — kept for an edge-to-edge data grid that
|
|
21
|
+
* genuinely wants hard rules, not the floating default.
|
|
17
22
|
* - "inset": a rounded, contained row — the wash is a rounded pill pulled in
|
|
18
23
|
* from the edge (radius 8, marginH -8). Lives inside a padded container (an
|
|
19
|
-
* `Accordion`'s drill-down rows, or a gap-separated selectable list)
|
|
20
|
-
* wash insets from the card edge instead of bleeding to it. Use when rows are
|
|
21
|
-
* GAP-separated rather than `Divider`-separated.
|
|
24
|
+
* `Accordion`'s drill-down rows, or a gap-separated selectable list).
|
|
22
25
|
*/
|
|
23
|
-
variant?: "bleed" | "inset";
|
|
26
|
+
variant?: "register" | "bleed" | "inset";
|
|
24
27
|
/** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
|
|
25
28
|
* claim their own presses; put the accessible door inside — a plain
|
|
26
29
|
* `Pressable` with role="button" + "Open …" label around the row body. */
|
|
@@ -42,7 +45,7 @@ export interface PressableRowProps {
|
|
|
42
45
|
* `variant` picks bleed (registers) or inset (Accordion drill-down rows).
|
|
43
46
|
*/
|
|
44
47
|
export function PressableRow(props: PressableRowProps) {
|
|
45
|
-
const { onPress, selected = false, marked = false, variant = "
|
|
48
|
+
const { onPress, selected = false, marked = false, variant = "register", children, style } = props;
|
|
46
49
|
const [hovered, setHovered] = useState(false);
|
|
47
50
|
|
|
48
51
|
// RN's types don't declare mouse handlers; react-native-web forwards them
|
|
@@ -62,7 +65,7 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
62
65
|
{...mouseProps}
|
|
63
66
|
style={({ pressed }) => [
|
|
64
67
|
styles.row,
|
|
65
|
-
variant === "inset" ? styles.inset : styles.bleed,
|
|
68
|
+
variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
|
|
66
69
|
{
|
|
67
70
|
backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
|
|
68
71
|
},
|
|
@@ -81,7 +84,16 @@ const styles = StyleSheet.create({
|
|
|
81
84
|
gap: 12,
|
|
82
85
|
...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
|
|
83
86
|
},
|
|
84
|
-
//
|
|
87
|
+
// THE register row — FULL-WIDTH rounded highlight. The hover/selected wash
|
|
88
|
+
// spans the entire row (so the pressable area === the row, never narrower than
|
|
89
|
+
// the dividers around it) and rounds (radius 10); content sits on the 20px
|
|
90
|
+
// column gutter so a Table header + cells align. The Divider between rows (the
|
|
91
|
+
// Table renders it) is the resting separation; this wash is the hover state.
|
|
92
|
+
register: {
|
|
93
|
+
borderRadius: 10,
|
|
94
|
+
paddingHorizontal: 20,
|
|
95
|
+
},
|
|
96
|
+
// Square, full-bleed — lines up with the Dividers between register rows. Legacy.
|
|
85
97
|
bleed: {
|
|
86
98
|
paddingHorizontal: 20,
|
|
87
99
|
},
|
package/src/sort_header.tsx
CHANGED
|
@@ -77,18 +77,23 @@ export function SortHeader(props: SortHeaderProps) {
|
|
|
77
77
|
const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
|
|
78
78
|
const loc = useLoticsLocale().sortHeader;
|
|
79
79
|
const active = sort?.key === sortKey;
|
|
80
|
-
|
|
80
|
+
// Always show a sort glyph so every sortable column READS as sortable — a faint
|
|
81
|
+
// up/down hint at rest, the solid direction chevron once active. (Before, the
|
|
82
|
+
// affordance only appeared after sorting, so columns looked un-sortable.)
|
|
83
|
+
const glyph = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down";
|
|
84
|
+
const glyphColor = active ? colors.zinc[600] : colors.zinc[400];
|
|
81
85
|
const dirText = active ? (sort.dir === "asc" ? (labels?.ascending ?? loc.ascending) : (labels?.descending ?? loc.descending)) : "";
|
|
82
86
|
const sortByLabel = (labels?.sortBy ?? loc.sortBy)(label);
|
|
83
87
|
|
|
84
88
|
return (
|
|
85
89
|
<PressableHighlight
|
|
86
|
-
focusRing
|
|
90
|
+
focusRing
|
|
91
|
+
accessibilityRole="button"
|
|
87
92
|
accessibilityLabel={`${sortByLabel}${dirText}`}
|
|
88
93
|
onPress={() => onSort(sortKey)}
|
|
89
94
|
style={[styles.header, align === "right" ? styles.right : null, style]}
|
|
90
95
|
>
|
|
91
|
-
{align === "right"
|
|
96
|
+
{align === "right" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
|
|
92
97
|
<Text
|
|
93
98
|
size="xs"
|
|
94
99
|
color={active ? "default" : "muted"}
|
|
@@ -98,7 +103,7 @@ export function SortHeader(props: SortHeaderProps) {
|
|
|
98
103
|
>
|
|
99
104
|
{label}
|
|
100
105
|
</Text>
|
|
101
|
-
{align === "left"
|
|
106
|
+
{align === "left" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
|
|
102
107
|
</PressableHighlight>
|
|
103
108
|
);
|
|
104
109
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Metric, type MetricFormat, type MetricTone } from "./metric";
|
|
4
|
+
import { TrendChip } from "./trend_chip";
|
|
5
|
+
import { InfoPopover } from "./info_popover";
|
|
6
|
+
|
|
7
|
+
export interface SummaryLineItem {
|
|
8
|
+
/** Label that reads AFTER the value ("below minimum", "stock value"). */
|
|
9
|
+
label: string;
|
|
10
|
+
value: number | string | null | undefined;
|
|
11
|
+
format?: MetricFormat;
|
|
12
|
+
currency?: string;
|
|
13
|
+
locale?: string;
|
|
14
|
+
emptyLabel?: string;
|
|
15
|
+
tone?: MetricTone;
|
|
16
|
+
/** Abbreviate large values (≥1 triệu → "486 tr"). */
|
|
17
|
+
compact?: boolean;
|
|
18
|
+
/** Optional ±% trend chip. */
|
|
19
|
+
trend?: number | null;
|
|
20
|
+
/** What this metric means — the ⓘ next to the value opens it in a popover. */
|
|
21
|
+
info?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SummaryLineProps {
|
|
25
|
+
items: SummaryLineItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A LIGHT, inline "value label" summary of the CURRENT view — sits BETWEEN the
|
|
30
|
+
* toolbar and a register/list and is recomputed from the FILTERED rows, so it
|
|
31
|
+
* always describes WHAT'S SHOWN (not the whole dataset). This is NOT the
|
|
32
|
+
* dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
|
|
33
|
+
* pages, left untouched). Here there's no card, no big numbers, no column rules
|
|
34
|
+
* — a small `Metric` value + a muted label, items wrapping, separated by space.
|
|
35
|
+
*/
|
|
36
|
+
export function SummaryLine(props: SummaryLineProps) {
|
|
37
|
+
const { items } = props;
|
|
38
|
+
return (
|
|
39
|
+
<View style={styles.row}>
|
|
40
|
+
{items.map((item) => (
|
|
41
|
+
<View key={item.label} style={styles.item}>
|
|
42
|
+
<Metric
|
|
43
|
+
value={item.value}
|
|
44
|
+
format={item.format}
|
|
45
|
+
currency={item.currency}
|
|
46
|
+
locale={item.locale}
|
|
47
|
+
emptyLabel={item.emptyLabel}
|
|
48
|
+
compact={item.compact}
|
|
49
|
+
tone={item.tone}
|
|
50
|
+
size="sm"
|
|
51
|
+
/>
|
|
52
|
+
<Text size="sm" color="muted">
|
|
53
|
+
{item.label}
|
|
54
|
+
</Text>
|
|
55
|
+
{item.trend != null ? <TrendChip value={item.trend} /> : null}
|
|
56
|
+
{item.info ? <InfoPopover text={item.info} accessibilityLabel={`About ${item.label}`} /> : null}
|
|
57
|
+
</View>
|
|
58
|
+
))}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
row: {
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
flexWrap: "wrap",
|
|
67
|
+
alignItems: "center",
|
|
68
|
+
columnGap: 22,
|
|
69
|
+
rowGap: 8,
|
|
70
|
+
},
|
|
71
|
+
item: {
|
|
72
|
+
flexDirection: "row",
|
|
73
|
+
alignItems: "center",
|
|
74
|
+
gap: 6,
|
|
75
|
+
},
|
|
76
|
+
});
|
package/src/table.tsx
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
|
|
11
11
|
import { Text } from "./text";
|
|
12
|
-
import {
|
|
12
|
+
import { colors } from "./colors";
|
|
13
13
|
import { PressableRow } from "./pressable_row";
|
|
14
|
+
import { Divider } from "./divider";
|
|
14
15
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
15
16
|
import { FOCUS_RING } from "./control_surface";
|
|
16
17
|
import { useFocusRing } from "./use_focus_ring";
|
|
@@ -54,6 +55,9 @@ export interface TableProps {
|
|
|
54
55
|
onSort?: (key: string) => void;
|
|
55
56
|
/** Localized a11y strings for the sortable headers. Omit to keep English. */
|
|
56
57
|
sortLabels?: SortHeaderLabels;
|
|
58
|
+
/** A node for the header's leading gutter — a select-all `CheckboxInput` aligned over
|
|
59
|
+
* the rows' `leading` checkboxes. Set `leading` too, to reserve the gutter width. */
|
|
60
|
+
selectAll?: ReactNode;
|
|
57
61
|
/** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
|
|
58
62
|
leading?: number;
|
|
59
63
|
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
@@ -71,13 +75,13 @@ export interface TableProps {
|
|
|
71
75
|
* non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
72
76
|
*/
|
|
73
77
|
export function Table(props: TableProps) {
|
|
74
|
-
const { columns, sort, onSort, sortLabels, leading = 0, trailing = 0, children } = props;
|
|
78
|
+
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
|
|
75
79
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
76
80
|
|
|
77
81
|
return (
|
|
78
82
|
<TableContext.Provider value={{ columns, leading, trailing }}>
|
|
79
83
|
<View style={styles.headerBand}>
|
|
80
|
-
{leading > 0 ? <View style={{ width: leading }}
|
|
84
|
+
{leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
|
|
81
85
|
{columns.map((col) => (
|
|
82
86
|
<View key={col.key} style={colStyle(col)}>
|
|
83
87
|
{col.label ? (
|
|
@@ -93,12 +97,14 @@ export function Table(props: TableProps) {
|
|
|
93
97
|
))}
|
|
94
98
|
{trailing > 0 ? <View style={{ width: trailing }} /> : null}
|
|
95
99
|
</View>
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
<View style={styles.body}>
|
|
101
|
+
{rows.map((row, i) => (
|
|
102
|
+
<View key={i}>
|
|
103
|
+
{i > 0 ? <Divider /> : null}
|
|
104
|
+
{row}
|
|
105
|
+
</View>
|
|
106
|
+
))}
|
|
107
|
+
</View>
|
|
102
108
|
</TableContext.Provider>
|
|
103
109
|
);
|
|
104
110
|
}
|
|
@@ -107,6 +113,8 @@ export interface TableRowProps {
|
|
|
107
113
|
/** Opens the record (the drawer). The door + the full-row surface both call it. */
|
|
108
114
|
onPress?: () => void;
|
|
109
115
|
selected?: boolean;
|
|
116
|
+
/** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
|
|
117
|
+
marked?: boolean;
|
|
110
118
|
/** Accessible name for the door (the keyboard-focusable open target). */
|
|
111
119
|
accessibilityLabel?: string;
|
|
112
120
|
/** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
|
|
@@ -127,7 +135,7 @@ export interface TableRowProps {
|
|
|
127
135
|
* win. Cells map to `Table`'s columns by position.
|
|
128
136
|
*/
|
|
129
137
|
export function TableRow(props: TableRowProps) {
|
|
130
|
-
const { onPress, selected, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
|
|
138
|
+
const { onPress, selected, marked, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
|
|
131
139
|
const ctx = useContext(TableContext);
|
|
132
140
|
if (!ctx) throw new Error("TableRow must be used within a Table");
|
|
133
141
|
|
|
@@ -137,7 +145,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
137
145
|
const { focusVisible, focusProps } = useFocusRing();
|
|
138
146
|
|
|
139
147
|
return (
|
|
140
|
-
<PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
|
|
148
|
+
<PressableRow onPress={onPress ?? noop} selected={selected} marked={marked} style={styles.row}>
|
|
141
149
|
{ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
|
|
142
150
|
<Pressable
|
|
143
151
|
accessibilityRole={onPress ? "button" : undefined}
|
|
@@ -168,12 +176,20 @@ export function TableCell(props: TableCellProps) {
|
|
|
168
176
|
const noop = () => {};
|
|
169
177
|
|
|
170
178
|
const styles = StyleSheet.create({
|
|
179
|
+
// A hairline under the column header anchors the columns; the rows below it are
|
|
180
|
+
// Divider-separated.
|
|
171
181
|
headerBand: {
|
|
172
182
|
paddingHorizontal: 20,
|
|
173
183
|
paddingVertical: 10,
|
|
174
184
|
flexDirection: "row",
|
|
175
185
|
alignItems: "center",
|
|
176
186
|
gap: 14,
|
|
187
|
+
borderBottomWidth: 1,
|
|
188
|
+
borderColor: colors.border,
|
|
189
|
+
},
|
|
190
|
+
// Rows are Divider-separated — the hairline between rows is the separation.
|
|
191
|
+
body: {
|
|
192
|
+
gap: 0,
|
|
177
193
|
},
|
|
178
194
|
row: {
|
|
179
195
|
gap: 14,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { useSelection } from "./use_selection";
|
|
5
|
+
|
|
6
|
+
describe("useSelection", () => {
|
|
7
|
+
it("toggles an id on and off, and sets it explicitly with `on`", () => {
|
|
8
|
+
const { result } = renderHook(() => useSelection());
|
|
9
|
+
expect(result.current.count).toBe(0);
|
|
10
|
+
expect(result.current.has("a")).toBe(false);
|
|
11
|
+
|
|
12
|
+
act(() => result.current.toggle("a"));
|
|
13
|
+
expect(result.current.has("a")).toBe(true);
|
|
14
|
+
expect(result.current.count).toBe(1);
|
|
15
|
+
|
|
16
|
+
act(() => result.current.toggle("a"));
|
|
17
|
+
expect(result.current.has("a")).toBe(false);
|
|
18
|
+
|
|
19
|
+
act(() => result.current.toggle("a", true));
|
|
20
|
+
act(() => result.current.toggle("a", true)); // explicit on is idempotent
|
|
21
|
+
expect(result.current.count).toBe(1);
|
|
22
|
+
act(() => result.current.toggle("a", false));
|
|
23
|
+
expect(result.current.has("a")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("setAll selects/deselects a batch; `selected` exposes the picked ids", () => {
|
|
27
|
+
const { result } = renderHook(() => useSelection());
|
|
28
|
+
act(() => result.current.setAll(["a", "b", "c"], true));
|
|
29
|
+
expect(result.current.count).toBe(3);
|
|
30
|
+
expect([...result.current.selected].sort()).toEqual(["a", "b", "c"]);
|
|
31
|
+
|
|
32
|
+
act(() => result.current.setAll(["a", "b"], false));
|
|
33
|
+
expect([...result.current.selected]).toEqual(["c"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("allSelected / indeterminate drive a select-all checkbox", () => {
|
|
37
|
+
const { result } = renderHook(() => useSelection());
|
|
38
|
+
const page = ["a", "b", "c"];
|
|
39
|
+
expect(result.current.allSelected(page)).toBe(false);
|
|
40
|
+
expect(result.current.indeterminate(page)).toBe(false);
|
|
41
|
+
expect(result.current.allSelected([])).toBe(false); // an empty set is never "all"
|
|
42
|
+
|
|
43
|
+
act(() => result.current.toggle("a", true));
|
|
44
|
+
expect(result.current.allSelected(page)).toBe(false);
|
|
45
|
+
expect(result.current.indeterminate(page)).toBe(true);
|
|
46
|
+
|
|
47
|
+
act(() => result.current.setAll(page, true));
|
|
48
|
+
expect(result.current.allSelected(page)).toBe(true);
|
|
49
|
+
expect(result.current.indeterminate(page)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("only the passed ids count — a selection outside the set isn't 'all'/'indeterminate' for it", () => {
|
|
53
|
+
const { result } = renderHook(() => useSelection());
|
|
54
|
+
act(() => result.current.setAll(["x", "y"], true));
|
|
55
|
+
expect(result.current.allSelected(["a", "b"])).toBe(false);
|
|
56
|
+
expect(result.current.indeterminate(["a", "b"])).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clear empties the whole selection", () => {
|
|
60
|
+
const { result } = renderHook(() => useSelection());
|
|
61
|
+
act(() => result.current.setAll(["a", "b"], true));
|
|
62
|
+
act(() => result.current.clear());
|
|
63
|
+
expect(result.current.count).toBe(0);
|
|
64
|
+
expect(result.current.has("a")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Selection {
|
|
4
|
+
/** The selected ids — read `.has(id)` / `.size`, or use the helpers below.
|
|
5
|
+
* Spread it (`[...selected]`) to hand the picked ids to a bulk action. */
|
|
6
|
+
selected: ReadonlySet<string>;
|
|
7
|
+
/** Whether `id` is currently selected. */
|
|
8
|
+
has: (id: string) => boolean;
|
|
9
|
+
/** How many ids are selected — across pages, so it's the running total a
|
|
10
|
+
* `FloatingActionBar` shows. */
|
|
11
|
+
count: number;
|
|
12
|
+
/** Toggle `id`, or set it explicitly when `on` is passed (a row checkbox's `onChange`). */
|
|
13
|
+
toggle: (id: string, on?: boolean) => void;
|
|
14
|
+
/** Set a whole batch of ids on/off at once — the controlled select-all `onChange(on)`. */
|
|
15
|
+
setAll: (ids: readonly string[], on: boolean) => void;
|
|
16
|
+
/** Are ALL of `ids` selected? — a select-all checkbox's `checked` (false for an empty set). */
|
|
17
|
+
allSelected: (ids: readonly string[]) => boolean;
|
|
18
|
+
/** SOME but not all of `ids` selected? — a select-all checkbox's `indeterminate`. */
|
|
19
|
+
indeterminate: (ids: readonly string[]) => boolean;
|
|
20
|
+
/** Clear the whole selection. */
|
|
21
|
+
clear: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Always-on multi-select state for a register / list: the `selected` Set plus the
|
|
26
|
+
* toggle, select-all, and all-or-some helpers a `Table` + `FloatingActionBar` need.
|
|
27
|
+
* Items are agnostic string ids — selectability GATING (which rows can be ticked)
|
|
28
|
+
* stays with the caller: filter to the selectable ids and pass THOSE to `setAll` /
|
|
29
|
+
* `allSelected` / `indeterminate` (a non-selectable row just gets a disabled
|
|
30
|
+
* checkbox). Selection persists across pages, so `count` is the running total.
|
|
31
|
+
* (For a files-grid that ENTERS a selection mode via tap-and-hold, that's
|
|
32
|
+
* `useSelectionMode`; this is the checkbox-always-visible register variant.)
|
|
33
|
+
*/
|
|
34
|
+
export function useSelection(): Selection {
|
|
35
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
|
36
|
+
|
|
37
|
+
const toggle = useCallback((id: string, on?: boolean) => {
|
|
38
|
+
setSelected((prev) => {
|
|
39
|
+
const next = new Set(prev);
|
|
40
|
+
if (on ?? !prev.has(id)) next.add(id);
|
|
41
|
+
else next.delete(id);
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const setAll = useCallback((ids: readonly string[], on: boolean) => {
|
|
47
|
+
setSelected((prev) => {
|
|
48
|
+
const next = new Set(prev);
|
|
49
|
+
for (const id of ids) {
|
|
50
|
+
if (on) next.add(id);
|
|
51
|
+
else next.delete(id);
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
});
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const clear = useCallback(() => setSelected(new Set()), []);
|
|
58
|
+
|
|
59
|
+
return useMemo<Selection>(
|
|
60
|
+
() => ({
|
|
61
|
+
selected,
|
|
62
|
+
has: (id) => selected.has(id),
|
|
63
|
+
count: selected.size,
|
|
64
|
+
toggle,
|
|
65
|
+
setAll,
|
|
66
|
+
allSelected: (ids) => ids.length > 0 && ids.every((id) => selected.has(id)),
|
|
67
|
+
indeterminate: (ids) => {
|
|
68
|
+
const all = ids.length > 0 && ids.every((id) => selected.has(id));
|
|
69
|
+
return !all && ids.some((id) => selected.has(id));
|
|
70
|
+
},
|
|
71
|
+
clear,
|
|
72
|
+
}),
|
|
73
|
+
[selected, toggle, setAll, clear],
|
|
74
|
+
);
|
|
75
|
+
}
|