@lotics/ui 7.7.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 +75 -33
- 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/combobox.tsx +28 -11
- 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/combobox.tsx
CHANGED
|
@@ -182,7 +182,13 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
182
182
|
const single = value ?? null;
|
|
183
183
|
const reflectedText = single ? (single.label ?? single.value) : "";
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
// The live edit buffer. `null` = the user isn't editing: the input reflects the
|
|
186
|
+
// committed selection's label and the list browses the FULL set. A string (incl.
|
|
187
|
+
// "") = the user is searching: the input shows it and the list filters by it.
|
|
188
|
+
// This one nullable carries the bit that (value, inputText) alone can't — after
|
|
189
|
+
// selecting FPP, the input reads "FPP" whether the user just reopened (browse)
|
|
190
|
+
// or retyped it (filter); `draft` is null in the first case, "FPP" in the second.
|
|
191
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
186
192
|
const [open, setOpen] = useState(autoFocus);
|
|
187
193
|
const triggerRef = useRef<View>(null);
|
|
188
194
|
const inputRef = useRef<RNTextInput>(null);
|
|
@@ -197,9 +203,11 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
197
203
|
const isServer = onSearchChange !== undefined;
|
|
198
204
|
const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
|
|
199
205
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
// What the input shows: the live draft while editing, else the reflected label.
|
|
207
|
+
const inputText = draft ?? (reflectSelection && single ? reflectedText : "");
|
|
208
|
+
// What the list filters by: nothing while reflecting a selection (browse), the
|
|
209
|
+
// draft while editing — so retyping the selected label still filters to it.
|
|
210
|
+
const filterQuery = draft ?? "";
|
|
203
211
|
|
|
204
212
|
const findOption = useCallback(
|
|
205
213
|
(val: T): PickerOption<T, D> =>
|
|
@@ -215,7 +223,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
215
223
|
debouncedSearch.cancel();
|
|
216
224
|
onSearchChange?.("");
|
|
217
225
|
}
|
|
218
|
-
|
|
226
|
+
// Stop editing — the input reflects the new label (or clears, when
|
|
227
|
+
// reflectSelection is off) and the next open browses the full set.
|
|
228
|
+
setDraft(null);
|
|
219
229
|
setOpen(false);
|
|
220
230
|
// The refocus below must not reopen the just-closed menu.
|
|
221
231
|
suppressFocusOpenRef.current = true;
|
|
@@ -247,7 +257,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
247
257
|
|
|
248
258
|
const handleChangeText = useCallback(
|
|
249
259
|
(text: string) => {
|
|
250
|
-
|
|
260
|
+
setDraft(text);
|
|
251
261
|
if (!disabled) setOpen(true);
|
|
252
262
|
if (isServer) debouncedSearch(text);
|
|
253
263
|
},
|
|
@@ -285,7 +295,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
285
295
|
// Boundary cast: the engine is generic per-instance, the context type is not.
|
|
286
296
|
// `ComboboxContent` casts back to its own `<T, D>` before touching rows.
|
|
287
297
|
list: list as unknown as UseOptionList<string, unknown>,
|
|
288
|
-
query,
|
|
298
|
+
query: inputText,
|
|
289
299
|
open,
|
|
290
300
|
showList,
|
|
291
301
|
loading,
|
|
@@ -349,9 +359,14 @@ export interface ComboboxInputProps {
|
|
|
349
359
|
export function ComboboxInput(props: ComboboxInputProps) {
|
|
350
360
|
const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
|
|
351
361
|
const ctx = useComboboxContext("ComboboxInput");
|
|
352
|
-
// Select mode (no leading icon):
|
|
353
|
-
//
|
|
354
|
-
|
|
362
|
+
// Select mode (no leading icon): the right slot holds the clear ✕ when the field
|
|
363
|
+
// carries text (a reflected selection or a live query) AND it's clearable;
|
|
364
|
+
// otherwise the trailing chevron, so an EMPTY picker still reads as a picker.
|
|
365
|
+
// The two never show together — ✕ replaces the chevron once there's something to
|
|
366
|
+
// clear, and pressing it resets the query + reopens the browse list (via
|
|
367
|
+
// `handleChangeText("")`) so the consumer's `onClear` lands back on the full set.
|
|
368
|
+
const showClear = clearable && ctx.query.length > 0;
|
|
369
|
+
const showChevron = !icon && !showClear;
|
|
355
370
|
|
|
356
371
|
return (
|
|
357
372
|
<View ref={ctx.triggerRef}>
|
|
@@ -375,7 +390,9 @@ export function ComboboxInput(props: ComboboxInputProps) {
|
|
|
375
390
|
autoCorrect={false}
|
|
376
391
|
accessibilityLabel={accessibilityLabel}
|
|
377
392
|
style={[
|
|
378
|
-
|
|
393
|
+
// Select mode reserves the right gutter for whichever trailing affordance
|
|
394
|
+
// is up (chevron when empty, ✕ when there's text) so neither overlaps it.
|
|
395
|
+
!icon ? styles.selectInput : undefined,
|
|
379
396
|
// Gate on `showList`, not `open`: an empty query with no options has
|
|
380
397
|
// `open=true` but renders NO dropdown, so a ring tied to `open` would
|
|
381
398
|
// show with nothing below it AND linger after blur (no popover fires a
|
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,
|