@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.
- package/package.json +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- 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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
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={
|
|
44
|
-
{
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
27
|
-
* current, and upcoming states and a connecting track.
|
|
28
|
-
*
|
|
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={
|
|
42
|
+
accessibilityLabel={a11y}
|
|
38
43
|
style={{ flexDirection: "row" }}
|
|
39
44
|
>
|
|
40
45
|
{steps.map((label, i) => {
|