@lotics/ui 2.4.1 → 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 (68) 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/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/sort_header.tsx +102 -0
  55. package/src/stacked_progress_bar.tsx +51 -16
  56. package/src/status_grid.tsx +187 -0
  57. package/src/step_list.tsx +128 -0
  58. package/src/step_progress.tsx +145 -0
  59. package/src/stepper.tsx +9 -4
  60. package/src/table.tsx +168 -112
  61. package/src/text.tsx +15 -0
  62. package/src/text_utils.ts +10 -0
  63. package/src/timeline.tsx +90 -57
  64. package/src/trend_footer.tsx +2 -2
  65. package/src/alert_row.tsx +0 -81
  66. package/src/table.web.tsx +0 -235
  67. package/src/table_picker.tsx +0 -305
  68. package/src/table_types.ts +0 -47
@@ -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
+ });
@@ -0,0 +1,145 @@
1
+ import { View, type ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+ import { useTooltip } from "./tooltip";
5
+
6
+ export interface StepProgressProps {
7
+ /** The stages: pass the NAMES (enables the built-in "4/7 · In" caption
8
+ * and per-segment hover names) or a bare count (bar only). */
9
+ steps: number | string[];
10
+ /** 0-based index of the stage in progress; earlier segments render
11
+ * complete. Pass -1 for "not started", `steps.length` for "complete". */
12
+ current: number;
13
+ /** Accent for the completed/current segments. Defaults to the neutral
14
+ * ink — pass a brand color to theme it. */
15
+ color?: string;
16
+ /** What this progress measures ("Production stages") — adds the xs muted
17
+ * uppercase eyebrow row above the bar, caption right. Omit at card
18
+ * density where the context already names it. */
19
+ title?: string;
20
+ /** Caption override for when the current stage needs prose ("In production") instead of the derived "3/6 · SX". */
21
+ label?: string;
22
+ /** Tone for the caption — `danger` flags a stalled/overdue stage (a stuck
23
+ * production order) in the FIXED caption spot, so the bar stays consistent
24
+ * instead of a floating badge shifting it. */
25
+ captionTone?: "default" | "danger";
26
+ /** Put the caption on its OWN line below a full-width bar (vs the default
27
+ * inline-right, which lets a varying caption shrink the bar). The compact,
28
+ * consistent card/drawer layout — no eyebrow, the bar never shifts. */
29
+ captionBelow?: boolean;
30
+ /** Segment height. Default 10 — matches ProgressBar's track; thinner reads
31
+ * weak. */
32
+ height?: number;
33
+ accessibilityLabel?: string;
34
+ }
35
+
36
+ /**
37
+ * THE compact stage indicator — N equal segments filled through the current
38
+ * stage, with a built-in "4/7 · In" caption when stages are named (hovering
39
+ * a segment names it). For *countable* stages an item walks through (a
40
+ * production line, a checklist, a pipeline) shown at card/list density.
41
+ *
42
+ * Picking a bar: continuous value-vs-max → `ProgressBar`; weighted
43
+ * composition/funnel → `StackedProgressBar`; a full-width wizard header
44
+ * where every milestone label must be visible → `Stepper`; everything
45
+ * stage-countable at card density → this.
46
+ */
47
+ export function StepProgress(props: StepProgressProps) {
48
+ const { steps, current, color = colors.zinc[900], title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
49
+ const captionColor = captionTone === "danger" ? "danger" : "muted";
50
+ const names = typeof steps === "number" ? null : steps;
51
+ const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
52
+ const isComplete = current >= count;
53
+ const safe = Math.min(current, count - 1);
54
+ const caption =
55
+ label ??
56
+ (names
57
+ ? isComplete
58
+ ? `${count}/${count} · Complete`
59
+ : `${Math.max(0, safe + 1)}/${count}${safe >= 0 ? ` · ${names[safe]}` : ""}`
60
+ : undefined);
61
+
62
+ const bar = (
63
+ <View
64
+ accessibilityRole="progressbar"
65
+ accessibilityLabel={accessibilityLabel ?? caption ?? `${Math.max(0, safe + 1)} of ${count}`}
66
+ style={{ flexDirection: "row", gap: 3, flex: 1 }}
67
+ >
68
+ {Array.from({ length: count }, (_, i) => (
69
+ <Segment
70
+ key={i}
71
+ name={names?.[i]}
72
+ color={color}
73
+ height={height}
74
+ // Complete settles ALL segments solid — "finished" reads as
75
+ // accomplished, not faded.
76
+ done={i < safe}
77
+ isCurrent={isComplete || (i === safe && safe >= 0)}
78
+ />
79
+ ))}
80
+ </View>
81
+ );
82
+
83
+ if (title) {
84
+ return (
85
+ <View style={{ gap: 6, flex: 1 }}>
86
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
87
+ <Text size="xs" color="muted" transform="uppercase">
88
+ {title}
89
+ </Text>
90
+ <View style={{ flex: 1 }} />
91
+ {caption ? (
92
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
93
+ {caption}
94
+ </Text>
95
+ ) : null}
96
+ </View>
97
+ {bar}
98
+ </View>
99
+ );
100
+ }
101
+
102
+ if (!caption) return bar;
103
+ if (captionBelow) {
104
+ return (
105
+ <View style={{ gap: 6, flex: 1 }}>
106
+ {bar}
107
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
108
+ {caption}
109
+ </Text>
110
+ </View>
111
+ );
112
+ }
113
+ return (
114
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
115
+ {bar}
116
+ <Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
117
+ {caption}
118
+ </Text>
119
+ </View>
120
+ );
121
+ }
122
+
123
+ function Segment(props: { name?: string; color: string; height: number; done: boolean; isCurrent: boolean }) {
124
+ const { name, color, height, done, isCurrent } = props;
125
+ // Hovering a segment names its stage — the bar stays self-explanatory
126
+ // even where the caption is elided. No-ops without a TooltipProvider.
127
+ const tooltip = useTooltip(name);
128
+ return (
129
+ <View
130
+ {...tooltip}
131
+ style={{
132
+ flex: 1,
133
+ height,
134
+ borderRadius: height / 2,
135
+ backgroundColor: done || isCurrent ? color : colors.zinc[100],
136
+ opacity: done && !isCurrent ? 0.4 : 1,
137
+ ...(done || isCurrent
138
+ ? null
139
+ : ({ boxShadow: "inset 0 0 0 1px rgba(38,38,38,0.04)" } as ViewStyle)),
140
+ // Progress that snaps is dead; progress that moves is alive.
141
+ ...({ transition: "background-color 200ms ease-out, opacity 200ms ease-out" } as ViewStyle),
142
+ }}
143
+ />
144
+ );
145
+ }
package/src/stepper.tsx CHANGED
@@ -23,18 +23,23 @@ function statusOf(index: number, current: number): StepStatus {
23
23
  }
24
24
 
25
25
  /**
26
- * Horizontal step / status tracker — a row of milestones with completed,
27
- * current, and upcoming states and a connecting track. For a vertical event
28
- * log use `Timeline` instead.
26
+ * Full-width wizard/milestone header — a row of labeled milestones with
27
+ * completed, current, and upcoming states and a connecting track. Every
28
+ * label is visible; use it where the journey itself is the headline (a
29
+ * record detail, a checkout). At card/list density use `StepProgress`
30
+ * (segments + built-in caption) instead. For a vertical event log use
31
+ * `Timeline`.
29
32
  */
30
33
  export function Stepper(props: StepperProps) {
31
34
  const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
32
35
  const last = steps.length - 1;
33
36
  const safe = Math.max(0, Math.min(current, last));
37
+ const a11y = accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`;
38
+
34
39
  return (
35
40
  <View
36
41
  accessibilityRole="progressbar"
37
- accessibilityLabel={accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`}
42
+ accessibilityLabel={a11y}
38
43
  style={{ flexDirection: "row" }}
39
44
  >
40
45
  {steps.map((label, i) => {