@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.
Files changed (59) hide show
  1. package/AGENTS.md +75 -33
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record.tsx +1 -1
  26. package/examples/tpl_record_plain.tsx +1 -1
  27. package/examples/tpl_report.tsx +1 -1
  28. package/examples/tpl_rollup.tsx +1 -1
  29. package/examples/tpl_settings.tsx +1 -1
  30. package/examples/tpl_shifts.tsx +1 -1
  31. package/examples/tpl_stock.tsx +1 -1
  32. package/examples/tpl_task_board.tsx +2 -2
  33. package/examples/tpl_tasks.tsx +1 -1
  34. package/examples/tpl_timeline.tsx +1 -1
  35. package/examples/tpl_tower.tsx +1 -1
  36. package/examples/tpl_triage.tsx +1 -1
  37. package/examples/tpl_wizard.tsx +1 -1
  38. package/package.json +3 -1
  39. package/src/card.tsx +6 -13
  40. package/src/combobox.tsx +28 -11
  41. package/src/data_grid.tsx +1 -1
  42. package/src/kpi_strip.tsx +5 -5
  43. package/src/pressable_row.tsx +22 -10
  44. package/src/sort_header.tsx +9 -4
  45. package/src/summary_line.tsx +76 -0
  46. package/src/table.tsx +27 -11
  47. package/src/use_selection.test.ts +66 -0
  48. package/src/use_selection.ts +75 -0
  49. package/examples/app_orders.tsx +0 -405
  50. package/examples/tpl_approvals.tsx +0 -375
  51. package/examples/tpl_batch.tsx +0 -234
  52. package/examples/tpl_callsheet.tsx +0 -481
  53. package/examples/tpl_convert.tsx +0 -478
  54. package/examples/tpl_crm_desk.tsx +0 -541
  55. package/examples/tpl_dispatch.tsx +0 -289
  56. package/examples/tpl_inventory.tsx +0 -299
  57. package/examples/tpl_pipeline.tsx +0 -400
  58. package/examples/tpl_reconcile.tsx +0 -275
  59. 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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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">
@@ -217,7 +217,7 @@ export function TplReport() {
217
217
  ];
218
218
 
219
219
  return (
220
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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.zinc[50] }}
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -188,7 +188,7 @@ export function TplStock() {
188
188
  ];
189
189
 
190
190
  return (
191
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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. Sibling: tpl_pipeline (same shape, deals).
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.zinc[50] },
555
+ actionHover: { backgroundColor: colors.white },
556
556
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
557
557
  });
@@ -426,7 +426,7 @@ const styles = StyleSheet.create({
426
426
  paddingHorizontal: 8,
427
427
  borderRadius: 10,
428
428
  },
429
- rowHover: { backgroundColor: colors.zinc[50] },
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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -162,7 +162,7 @@ export function TplTower() {
162
162
  : 0;
163
163
 
164
164
  return (
165
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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 }}>
@@ -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.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
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.7.0",
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
- const [query, setQuery] = useState(reflectSelection && single ? reflectedText : "");
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
- // The displayed text equals the reflected selection until the user types show
201
- // ALL options then (browse), not the list filtered to the selected label.
202
- const filterQuery = reflectSelection && single && query === reflectedText ? "" : query;
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
- setQuery(reflectSelection ? (opt.label ?? opt.value) : "");
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
- setQuery(text);
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): a trailing chevron so the field reads as a
353
- // picker, not a free-text search. The clear owns the right slot when present.
354
- const showChevron = !icon && !clearable;
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
- showChevron ? styles.selectInput : undefined,
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 examples: `tpl_task_board`, `tpl_pipeline`.
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 at the top of a screen — one Card, hairline-divided columns
38
- * of KPICard with a uniform value scale. Screens must not hand-compose stat
39
- * strips: the same four-numbers-in-a-row should look identical on a
40
- * dashboard, an inventory register, or a payroll page. Wraps on narrow
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;
@@ -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
- * - "bleed" (default): the standalone REGISTER row px-20, square wash to
15
- * the card edges, separated by full-bleed `Divider`s. The one pattern for
16
- * top-level record lists.
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) so the
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 = "bleed", children, style } = props;
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
- // Square, full-bleedlines up with the Dividers between register rows.
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
  },
@@ -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
- const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
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 accessibilityRole="button"
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" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
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" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
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 { Divider } from "./divider";
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 }} /> : null}
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
- {rows.map((row, i) => (
97
- <View key={(row as ReactElement).key ?? i}>
98
- <Divider />
99
- {row}
100
- </View>
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,