@lotics/ui 2.4.0 → 2.5.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 (69) hide show
  1. package/package.json +27 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/avatar.web.tsx +102 -0
  6. package/src/badge.tsx +40 -9
  7. package/src/breakdown.tsx +121 -0
  8. package/src/card.tsx +150 -0
  9. package/src/cell_select.tsx +3 -2
  10. package/src/chip_group.tsx +65 -0
  11. package/src/colors.ts +61 -0
  12. package/src/column_filter.tsx +9 -24
  13. package/src/completion_state.tsx +43 -0
  14. package/src/control_surface.ts +32 -0
  15. package/src/counter.tsx +58 -0
  16. package/src/date_range_filter_field.tsx +44 -12
  17. package/src/detail_row.tsx +45 -0
  18. package/src/dialog.tsx +0 -24
  19. package/src/download.ts +2 -1
  20. package/src/drawer.tsx +94 -2
  21. package/src/empty_state.tsx +37 -0
  22. package/src/file_badge.tsx +27 -4
  23. package/src/file_dropzone.tsx +188 -0
  24. package/src/file_picker.ts +45 -0
  25. package/src/filter_pill.tsx +106 -0
  26. package/src/floating_action_bar.tsx +57 -0
  27. package/src/fonts.css +10 -13
  28. package/src/format_money.ts +38 -0
  29. package/src/heatmap.tsx +153 -0
  30. package/src/icon.tsx +2 -0
  31. package/src/icon_button.tsx +16 -2
  32. package/src/index.css +4 -3
  33. package/src/info_popover.tsx +4 -6
  34. package/src/kpi_card.tsx +19 -6
  35. package/src/kpi_strip.tsx +89 -0
  36. package/src/line_chart.tsx +61 -34
  37. package/src/link_button.tsx +50 -0
  38. package/src/metric.tsx +21 -12
  39. package/src/pagination.tsx +5 -9
  40. package/src/peek.tsx +68 -0
  41. package/src/picker.tsx +13 -1
  42. package/src/picker_menu.tsx +8 -16
  43. package/src/pie_chart.tsx +29 -8
  44. package/src/pill_button.tsx +10 -8
  45. package/src/popover.tsx +14 -4
  46. package/src/pressable_highlight.tsx +10 -1
  47. package/src/pressable_row.tsx +91 -0
  48. package/src/progress_bar.tsx +47 -17
  49. package/src/radio_picker.tsx +20 -9
  50. package/src/range_slider.tsx +185 -0
  51. package/src/remainder_meter.tsx +48 -0
  52. package/src/ring_gauge.tsx +5 -5
  53. package/src/scan_field.tsx +58 -0
  54. package/src/search_input.tsx +12 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors, solid } from "./colors";
3
+ import { Text } from "./text";
4
+ import { TextInputField } from "./text_input_field";
5
+
6
+ export type ScanStatus = "idle" | "match" | "mismatch";
7
+
8
+ export interface ScanFieldProps {
9
+ value: string;
10
+ onChangeText: (text: string) => void;
11
+ placeholder?: string;
12
+ /** Verify state — drives the border colour, the leading glyph, and the inline
13
+ * message. Stays "idle" until the consumer compares the scan to the expected
14
+ * code and reports the result. */
15
+ status?: ScanStatus;
16
+ /** Inline confirmation shown when `status` is "match". */
17
+ matchHint?: string;
18
+ /** Inline correction shown when `status` is "mismatch". */
19
+ mismatchHint?: string;
20
+ /** Fired on Enter — a scan gun sends a return after the code, so this is the
21
+ * "scanned" signal (also a manual submit). */
22
+ onScan?: () => void;
23
+ accessibilityLabel?: string;
24
+ }
25
+
26
+ /**
27
+ * The scan/verify input for operations work — scan (or type) a code to confirm
28
+ * you're at the right bin, on the right item, handling the right tote. Unlike a
29
+ * plain field it carries a VERIFY state: the border, the leading glyph, and an
30
+ * inline message go green on a match and red on a mismatch, giving the operator a
31
+ * go / no-go before they act. Reused across pick / pack / receive / count / ship.
32
+ */
33
+ export function ScanField(props: ScanFieldProps) {
34
+ const { value, onChangeText, placeholder, status = "idle", matchHint, mismatchHint, onScan, accessibilityLabel } = props;
35
+ const borderColor = status === "match" ? solid("emerald") : status === "mismatch" ? solid("red") : colors.zinc[300];
36
+ return (
37
+ <View style={{ gap: 6 }}>
38
+ <TextInputField
39
+ value={value}
40
+ onChangeText={onChangeText}
41
+ onSubmitEditing={onScan}
42
+ placeholder={placeholder}
43
+ icon={status === "match" ? "circle-check" : "scan"}
44
+ accessibilityLabel={accessibilityLabel}
45
+ style={[styles.field, { borderColor }]}
46
+ />
47
+ {status === "match" && matchHint ? <Text size="xs" color="success">{matchHint}</Text> : null}
48
+ {status === "mismatch" && mismatchHint ? <Text size="xs" color="danger">{mismatchHint}</Text> : null}
49
+ </View>
50
+ );
51
+ }
52
+
53
+ const styles = StyleSheet.create({
54
+ field: {
55
+ borderRadius: 10,
56
+ backgroundColor: colors.white,
57
+ },
58
+ });
@@ -8,6 +8,7 @@ import {
8
8
  View,
9
9
  type ViewStyle,
10
10
  } from "react-native";
11
+ import { colors } from "./colors";
11
12
  import { TextInputField } from "./text_input_field";
12
13
 
13
14
  type SearchInputProps = Omit<
@@ -55,7 +56,18 @@ export function SearchInput(props: SearchInputProps) {
55
56
  }
56
57
 
57
58
  const styles = StyleSheet.create({
59
+ // Search reads as a view-control by SHAPE (rounded pill + the leading
60
+ // search glyph). White fill pops against the zinc-50 canvas where most
61
+ // toolbars live; a zinc-300 border (one step up from the form-input
62
+ // hairline) keeps it defined on white cards too, where the fill alone
63
+ // would vanish. 40px (TextInputField default) — the system control height
64
+ // every band aligns to.
58
65
  pill: {
59
66
  borderRadius: 999,
67
+ backgroundColor: colors.white,
68
+ // A thin 1px resting border (TextInputField's default width) — emphasis on
69
+ // interaction comes from the input's own focus outline, so the pill stays
70
+ // quiet at rest. zinc-300 keeps it defined on white cards.
71
+ borderColor: colors.zinc[300],
60
72
  },
61
73
  });
@@ -0,0 +1,102 @@
1
+ import { StyleSheet, type ViewStyle } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+
7
+ export type SortDir = "asc" | "desc";
8
+ export interface SortState {
9
+ key: string;
10
+ dir: SortDir;
11
+ }
12
+
13
+ /**
14
+ * Cycle a SINGLE-column sort on press: none → asc → desc → none (the third
15
+ * toggle clears it). The parent holds one `SortState | null`.
16
+ */
17
+ export function cycleSort(current: SortState | null, key: string): SortState | null {
18
+ if (current?.key !== key) return { key, dir: "asc" };
19
+ if (current.dir === "asc") return { key, dir: "desc" };
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * Order a COPY of `items` by the active column. `getValue` maps (item, key) to a
25
+ * comparable (lowercase strings for case-insensitive order). Returns `items`
26
+ * unchanged when nothing is sorted.
27
+ */
28
+ export function sortBy<T>(
29
+ items: T[],
30
+ sort: SortState | null,
31
+ getValue: (item: T, key: string) => string | number,
32
+ ): T[] {
33
+ if (!sort) return items;
34
+ const dir = sort.dir === "asc" ? 1 : -1;
35
+ return [...items].sort((a, b) => {
36
+ const va = getValue(a, sort.key);
37
+ const vb = getValue(b, sort.key);
38
+ return va < vb ? -dir : va > vb ? dir : 0;
39
+ });
40
+ }
41
+
42
+ export interface SortHeaderProps {
43
+ label: string;
44
+ sortKey: string;
45
+ sort: SortState | null;
46
+ onSort: (key: string) => void;
47
+ /** Right-align for numeric columns — the arrow then sits LEFT of the label. */
48
+ align?: "left" | "right";
49
+ style?: ViewStyle;
50
+ }
51
+
52
+ /**
53
+ * A sortable column header — the eyebrow label's pressable sibling. Press
54
+ * cycles a single-column sort (none → asc → desc → none) via `cycleSort`,
55
+ * showing a direction chevron when active; the parent orders rows with `sortBy`.
56
+ * One sort at a time. The hover wash bleeds (negative margin) so the label stays
57
+ * flush with the column content beneath it.
58
+ */
59
+ export function SortHeader(props: SortHeaderProps) {
60
+ const { label, sortKey, sort, onSort, align = "left", style } = props;
61
+ const active = sort?.key === sortKey;
62
+ const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
63
+ const dirText = active ? (sort.dir === "asc" ? ", ascending" : ", descending") : "";
64
+
65
+ return (
66
+ <PressableHighlight
67
+ accessibilityRole="button"
68
+ accessibilityLabel={`Sort by ${label}${dirText}`}
69
+ onPress={() => onSort(sortKey)}
70
+ style={[styles.header, align === "right" ? styles.right : null, style]}
71
+ >
72
+ {align === "right" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
73
+ <Text
74
+ size="xs"
75
+ color={active ? "default" : "muted"}
76
+ weight={active ? "medium" : "regular"}
77
+ transform="uppercase"
78
+ numberOfLines={1}
79
+ >
80
+ {label}
81
+ </Text>
82
+ {align === "left" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
83
+ </PressableHighlight>
84
+ );
85
+ }
86
+
87
+ const styles = StyleSheet.create({
88
+ // Negative margin so the wash bleeds while the label stays flush with the
89
+ // column content below (same idea as LinkButton).
90
+ header: {
91
+ flexDirection: "row",
92
+ alignItems: "center",
93
+ gap: 4,
94
+ paddingVertical: 4,
95
+ paddingHorizontal: 6,
96
+ marginHorizontal: -6,
97
+ borderRadius: 6,
98
+ },
99
+ right: {
100
+ justifyContent: "flex-end",
101
+ },
102
+ });
@@ -1,5 +1,6 @@
1
1
  import { View, StyleSheet } from "react-native";
2
2
  import { colors } from "./colors";
3
+ import { Text } from "./text";
3
4
 
4
5
  interface Segment {
5
6
  /** Identifier for the segment — used as a React key. */
@@ -16,6 +17,11 @@ interface StackedProgressBarProps {
16
17
  * states render consistently (an empty array would otherwise look like
17
18
  * "data loaded with no values"). */
18
19
  total: number;
20
+ /** What this bar breaks down ("Phễu bán hàng") — the xs muted uppercase
21
+ * eyebrow above the bar. Omit inside a band that already names it. */
22
+ title?: string;
23
+ /** Context above-right of the bar ("97 cơ hội"). xs muted tabular. */
24
+ caption?: string;
19
25
  /** Bar pixel height. Default 14 — the dashboard hero-progress size.
20
26
  * Drop to 6-8 for inline status bars in tight rows; bump to 20-24
21
27
  * when the bar IS the section's main visualization. */
@@ -30,30 +36,59 @@ interface StackedProgressBarProps {
30
36
  * width, and a single dominant value renders as one long segment rather
31
37
  * than a "broken" bar chart with five empty stages.
32
38
  *
33
- * Pair with `<LegendItem />` rows below to name the colored segments.
34
- * Without a legend, the colored bar is decorative — viewers can't map
35
- * colors back to meaning.
39
+ * Shares the labeled-meter anatomy with `ProgressBar`/`StepProgress`:
40
+ * optional title eyebrow left, caption right, bar below. Pair with
41
+ * `<LegendItem />` rows below to name the colored segments — without a
42
+ * legend, the colored bar is decorative.
36
43
  */
37
44
  export function StackedProgressBar(props: StackedProgressBarProps) {
38
- const { segments, total, height = 14, loading } = props;
39
- if (loading || total === 0) {
40
- return <View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />;
41
- }
45
+ const { segments, total, title, caption, height = 14, loading } = props;
46
+ const bar =
47
+ loading || total === 0 ? (
48
+ <View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />
49
+ ) : (
50
+ <View style={[styles.bar, styles.barFilled, { height }]}>
51
+ {segments
52
+ .filter((s) => s.value > 0)
53
+ .map((seg) => (
54
+ <View key={seg.key} style={{ flex: seg.value, backgroundColor: seg.color }} />
55
+ ))}
56
+ </View>
57
+ );
58
+
59
+ if (!title && !caption) return bar;
42
60
  return (
43
- <View style={[styles.bar, styles.barFilled, { height }]}>
44
- {segments
45
- .filter((s) => s.value > 0)
46
- .map((seg) => (
47
- <View
48
- key={seg.key}
49
- style={{ flex: seg.value, backgroundColor: seg.color }}
50
- />
51
- ))}
61
+ <View style={styles.container}>
62
+ <View style={styles.header}>
63
+ {title ? (
64
+ <Text size="xs" color="muted" transform="uppercase">
65
+ {title}
66
+ </Text>
67
+ ) : null}
68
+ <View style={styles.spacer} />
69
+ {caption ? (
70
+ <Text size="xs" color="muted" tabular>
71
+ {caption}
72
+ </Text>
73
+ ) : null}
74
+ </View>
75
+ {bar}
52
76
  </View>
53
77
  );
54
78
  }
55
79
 
56
80
  const styles = StyleSheet.create({
81
+ container: {
82
+ gap: 6,
83
+ },
84
+ header: {
85
+ flexDirection: "row",
86
+ alignItems: "baseline",
87
+ gap: 12,
88
+ },
89
+ spacer: {
90
+ flex: 1,
91
+ },
57
92
  bar: {
58
93
  borderRadius: 999,
59
94
  overflow: "hidden",
@@ -0,0 +1,187 @@
1
+ import { useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { colors, solid, tint, type ColorName } from "./colors";
4
+ import { PressableHighlight } from "./pressable_highlight";
5
+ import { Text } from "./text";
6
+
7
+ export interface StatusGridState {
8
+ key: string;
9
+ label: string;
10
+ /** Palette family name — the cell fill and legend dot derive their shades
11
+ * from it (solid for the cell, a tint when dimmed). Pass the name, not a
12
+ * hex, so one state is one color everywhere. */
13
+ color: ColorName;
14
+ }
15
+
16
+ export interface StatusGridItem {
17
+ key: string;
18
+ label: string;
19
+ state: string;
20
+ }
21
+
22
+ export interface StatusGridProps {
23
+ items: StatusGridItem[];
24
+ states: StatusGridState[];
25
+ /** The drilled-in state (from `StatusLegend`). Cells in other states dim
26
+ * and stop being pressable — they're outside the current slice. */
27
+ selectedState?: string | null;
28
+ onPressItem?: (key: string) => void;
29
+ /** The open/highlighted item (e.g. the unit in the drawer). */
30
+ activeKey?: string | null;
31
+ /** Cell edge in px. Default 16 — readable at hundreds of cells. */
32
+ cellSize?: number;
33
+ }
34
+
35
+ /**
36
+ * Live-state scan for hundreds of units — the wallboard pattern (machine
37
+ * park, fleet, gate bank, sensor field). Every unit is one color-coded
38
+ * cell; the job is scanning for red, not reading labels. Pressing a cell
39
+ * opens the unit; selecting a state in `StatusLegend` dims everything else.
40
+ * Render one grid per group (zone, site) and share a single legend +
41
+ * selection in the host — same lifted-state idiom as `Breakdown`.
42
+ */
43
+ export function StatusGrid(props: StatusGridProps) {
44
+ const { items, states, selectedState = null, onPressItem, activeKey = null, cellSize = 16 } = props;
45
+ const stateOf = (key: string) => states.find((s) => s.key === key);
46
+
47
+ return (
48
+ <View style={styles.grid}>
49
+ {items.map((item) => {
50
+ const state = stateOf(item.state);
51
+ const dimmed = selectedState !== null && item.state !== selectedState;
52
+ const cellColor = state ? (dimmed ? tint(state.color, 0.2) : solid(state.color)) : colors.zinc[300];
53
+ return (
54
+ <Cell
55
+ key={item.key}
56
+ label={`${item.label} — ${state?.label ?? item.state}`}
57
+ color={cellColor}
58
+ size={cellSize}
59
+ active={activeKey === item.key}
60
+ onPress={onPressItem && !dimmed ? () => onPressItem(item.key) : undefined}
61
+ />
62
+ );
63
+ })}
64
+ </View>
65
+ );
66
+ }
67
+
68
+ interface CellProps {
69
+ label: string;
70
+ color: string;
71
+ size: number;
72
+ active: boolean;
73
+ onPress?: () => void;
74
+ }
75
+
76
+ function Cell(props: CellProps) {
77
+ const { label, color, size, active, onPress } = props;
78
+ const [hovered, setHovered] = useState(false);
79
+ const base = {
80
+ width: size,
81
+ height: size,
82
+ backgroundColor: color,
83
+ borderColor: active ? colors.zinc[900] : hovered && onPress ? "rgba(255,255,255,0.75)" : "transparent",
84
+ };
85
+ if (!onPress) return <View style={[styles.cell, base]} />;
86
+ return (
87
+ <Pressable
88
+ accessibilityRole="button"
89
+ accessibilityLabel={label}
90
+ onPress={onPress}
91
+ onHoverIn={() => setHovered(true)}
92
+ onHoverOut={() => setHovered(false)}
93
+ style={[styles.cell, base]}
94
+ />
95
+ );
96
+ }
97
+
98
+ export interface StatusLegendProps {
99
+ /** ALL items the legend summarizes (across every grid it governs). */
100
+ items: StatusGridItem[];
101
+ states: StatusGridState[];
102
+ selectedKey?: string | null;
103
+ /** Press a state to drill into it (press again to clear). Omit for an
104
+ * informational legend. */
105
+ onSelect?: (key: string | null) => void;
106
+ }
107
+
108
+ /**
109
+ * The legend + counts for one or more `StatusGrid`s. Pressable states drill
110
+ * the grids (host holds the selection); counts are derived here so the
111
+ * legend can never disagree with the cells.
112
+ */
113
+ export function StatusLegend(props: StatusLegendProps) {
114
+ const { items, states, selectedKey = null, onSelect } = props;
115
+
116
+ return (
117
+ <View style={styles.legend}>
118
+ {states.map((state) => {
119
+ const count = items.reduce((n, item) => n + (item.state === state.key ? 1 : 0), 0);
120
+ const selected = selectedKey === state.key;
121
+ const dimmed = selectedKey !== null && !selected;
122
+ const row = (
123
+ <>
124
+ <View style={[styles.swatch, { backgroundColor: dimmed ? tint(state.color, 0.3) : solid(state.color) }]} />
125
+ <Text size="xs" color={dimmed ? "muted" : "default"}>{state.label}</Text>
126
+ <Text size="xs" weight={selected ? "semibold" : "regular"} color={dimmed ? "muted" : "default"} tabular>
127
+ {count.toLocaleString("en-US")}
128
+ </Text>
129
+ </>
130
+ );
131
+ return onSelect ? (
132
+ <PressableHighlight
133
+ key={state.key}
134
+ accessibilityRole="button"
135
+ accessibilityState={{ selected }}
136
+ accessibilityLabel={`${state.label}: ${count}`}
137
+ onPress={() => onSelect(selected ? null : state.key)}
138
+ style={[styles.legendItem, styles.legendPressable, selected ? styles.legendSelected : null]}
139
+ >
140
+ {row}
141
+ </PressableHighlight>
142
+ ) : (
143
+ <View key={state.key} style={styles.legendItem}>
144
+ {row}
145
+ </View>
146
+ );
147
+ })}
148
+ </View>
149
+ );
150
+ }
151
+
152
+ const styles = StyleSheet.create({
153
+ grid: {
154
+ flexDirection: "row",
155
+ flexWrap: "wrap",
156
+ gap: 3,
157
+ },
158
+ cell: {
159
+ borderRadius: 4,
160
+ borderWidth: 2,
161
+ },
162
+ legend: {
163
+ flexDirection: "row",
164
+ flexWrap: "wrap",
165
+ alignItems: "center",
166
+ columnGap: 8,
167
+ rowGap: 4,
168
+ },
169
+ legendItem: {
170
+ flexDirection: "row",
171
+ alignItems: "center",
172
+ gap: 6,
173
+ minHeight: 28,
174
+ },
175
+ legendPressable: {
176
+ borderRadius: 6,
177
+ paddingHorizontal: 8,
178
+ },
179
+ legendSelected: {
180
+ backgroundColor: colors.zinc[100],
181
+ },
182
+ swatch: {
183
+ width: 8,
184
+ height: 8,
185
+ borderRadius: 999,
186
+ },
187
+ });
@@ -0,0 +1,128 @@
1
+ import { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { PressableHighlight } from "./pressable_highlight";
7
+
8
+ export type StepStatus = "pending" | "current" | "done" | "warning";
9
+
10
+ export interface StepListItem {
11
+ id: string;
12
+ status: StepStatus;
13
+ /** Primary line — a stage, a code, a name. */
14
+ title: string;
15
+ /** Secondary muted line. */
16
+ subtitle?: string;
17
+ /** Right-aligned node — a count, a `Badge`, a time. */
18
+ trailing?: ReactNode;
19
+ }
20
+
21
+ export interface StepListProps {
22
+ steps: StepListItem[];
23
+ /** When set, steps become pressable to navigate; the matching step is held
24
+ * highlighted so its panel can be shown elsewhere. */
25
+ selectedId?: string;
26
+ onStepPress?: (id: string) => void;
27
+ accessibilityLabel?: string;
28
+ }
29
+
30
+ /**
31
+ * A vertical step sequence on a connecting spine — done · now · up next — for a
32
+ * guided run, a staged process, a checklist. Every status is the SAME 20px node
33
+ * (filled once reached, hollow while pending), so the spine reads as one line no
34
+ * matter which statuses appear or in what order. Unlike `Stepper` (horizontal) or
35
+ * `StepProgress` (a bar), and unlike `Timeline` (past events only), it models
36
+ * future/pending steps. Pass `onStepPress` to make the steps NAVIGABLE — the
37
+ * selected step is held highlighted so the host can show its panel beside the list.
38
+ */
39
+ export function StepList(props: StepListProps) {
40
+ const { steps, selectedId, onStepPress, accessibilityLabel } = props;
41
+ return (
42
+ <View accessibilityLabel={accessibilityLabel}>
43
+ {steps.map((s, i) => {
44
+ // The focused step: the navigated one when navigable, else "now".
45
+ const active = selectedId != null ? s.id === selectedId : s.status === "current";
46
+ const past = s.status === "done" || s.status === "warning";
47
+ const content = (
48
+ <View style={styles.contentRow}>
49
+ <View style={{ flex: 1, gap: 1 }}>
50
+ <Text size="sm" weight={active ? "medium" : "regular"} color={past && !active ? "muted" : "default"}>
51
+ {s.title}
52
+ </Text>
53
+ {s.subtitle ? (
54
+ <Text size="xs" color="muted" numberOfLines={1}>{s.subtitle}</Text>
55
+ ) : null}
56
+ </View>
57
+ {s.trailing}
58
+ </View>
59
+ );
60
+ return (
61
+ <View key={s.id} style={styles.item}>
62
+ <View style={styles.spineCol}>
63
+ <Marker status={s.status} />
64
+ {i < steps.length - 1 ? <View style={styles.spine} /> : null}
65
+ </View>
66
+ <View style={[styles.contentCol, i < steps.length - 1 ? styles.contentGap : null]}>
67
+ {onStepPress ? (
68
+ <PressableHighlight onPress={() => onStepPress(s.id)} style={[styles.rowBox, active ? styles.active : null]}>
69
+ {content}
70
+ </PressableHighlight>
71
+ ) : (
72
+ <View style={[styles.rowBox, active ? styles.active : null]}>{content}</View>
73
+ )}
74
+ </View>
75
+ </View>
76
+ );
77
+ })}
78
+ </View>
79
+ );
80
+ }
81
+
82
+ /** One uniform 20px node; status drives fill, not size or shape. */
83
+ function Marker({ status }: { status: StepStatus }) {
84
+ if (status === "pending") {
85
+ return <View style={[styles.disc, styles.discPending]} />;
86
+ }
87
+ if (status === "current") {
88
+ return (
89
+ <View style={[styles.disc, { backgroundColor: solid("blue") }]}>
90
+ <View style={styles.currentDot} />
91
+ </View>
92
+ );
93
+ }
94
+ if (status === "warning") {
95
+ return (
96
+ <View style={[styles.disc, { backgroundColor: solid("amber") }]}>
97
+ <View style={styles.shortBar} />
98
+ </View>
99
+ );
100
+ }
101
+ return (
102
+ <View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
103
+ <Icon name="check" size={12} color={colors.background} />
104
+ </View>
105
+ );
106
+ }
107
+
108
+ const NODE = 20;
109
+
110
+ const styles = StyleSheet.create({
111
+ item: { flexDirection: "row", gap: 12 },
112
+ // paddingTop pairs with rowBox.paddingVertical so the node centres on the
113
+ // FIRST line of content, not on a multi-line block.
114
+ spineCol: { width: NODE, alignItems: "center", paddingTop: 4 },
115
+ disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
116
+ discPending: { backgroundColor: colors.background, borderWidth: 1.5, borderColor: colors.zinc[300] },
117
+ currentDot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.background },
118
+ shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.background },
119
+ spine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 4 },
120
+ contentCol: { flex: 1 },
121
+ // The inter-step gap — on every step but the last, so the list ends flush and
122
+ // a container's own padding isn't doubled at the bottom.
123
+ contentGap: { paddingBottom: 12 },
124
+ contentRow: { flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 },
125
+ // press + plain share one box so the active wash looks identical either way.
126
+ rowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginHorizontal: -10, flexDirection: "row", alignItems: "flex-start" },
127
+ active: { backgroundColor: colors.zinc[100] },
128
+ });