@lotics/ui 5.3.0 → 5.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/src/stepper.tsx CHANGED
@@ -1,88 +1,229 @@
1
- import { View } from "react-native";
2
- import { colors } from "./colors";
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ createContext,
5
+ isValidElement,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { Animated, StyleSheet, View } from "react-native";
13
+ import { colors, solid } from "./colors";
3
14
  import { Icon } from "./icon";
4
15
  import { Text } from "./text";
16
+ import { PressableHighlight } from "./pressable_highlight";
17
+ import { AnimationFadeIn } from "./animation_fade_in";
5
18
 
6
- export type StepStatus = "done" | "current" | "upcoming";
19
+ // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
20
+ // where we are (ring + white centre, pulses when live); `done` = passed (filled);
21
+ // `complete` = the terminal "finished" marker (filled + check); `warning` = an
22
+ // issue at a reached step.
23
+ export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
24
+ export type StepOrientation = "horizontal" | "vertical";
25
+
26
+ const reached = (s: StepStatus) => s !== "upcoming";
27
+
28
+ interface StepperConfig {
29
+ orientation: StepOrientation;
30
+ color: string;
31
+ /** Pulse the current node — signals an actively-running sequence (an agent
32
+ * run streaming its steps). Off for a static wizard/checklist. */
33
+ live: boolean;
34
+ }
35
+ const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false });
7
36
 
8
37
  export interface StepperProps {
9
- /** Ordered step labels, left right. */
10
- steps: string[];
11
- /** Index of the in-progress step; lower indices render complete. */
12
- current: number;
13
- /** Accent for the completed track, current ring, and current label.
14
- * Defaults to the neutral ink — pass a brand color to theme it. */
38
+ /** Compound form: `<Stepper><Step status>…</Step></Stepper>` swappable content. */
39
+ children?: ReactNode;
40
+ /** Data form: ordered labels; `current` derives each step's status. Ignored when `children` is set. */
41
+ steps?: string[];
42
+ current?: number;
43
+ orientation?: StepOrientation;
44
+ live?: boolean;
45
+ /** Accent ink for reached nodes + the connecting track. Defaults to neutral ink. */
15
46
  color?: string;
16
47
  accessibilityLabel?: string;
17
48
  }
18
49
 
19
- function statusOf(index: number, current: number): StepStatus {
20
- if (index < current) return "done";
21
- if (index === current) return "current";
22
- return "upcoming";
50
+ interface StepPositional {
51
+ _last?: boolean;
52
+ _leftFilled?: boolean;
53
+ _rightFilled?: boolean;
54
+ }
55
+
56
+ export interface StepProps extends StepPositional {
57
+ status: StepStatus;
58
+ children: ReactNode;
59
+ /** Make the step navigable; the active one holds a wash so a panel can sit beside it (vertical). */
60
+ onPress?: () => void;
61
+ active?: boolean;
62
+ accessibilityLabel?: string;
23
63
  }
24
64
 
25
65
  /**
26
- * Full-width wizard/milestone headera 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`.
66
+ * Progress through an ordered sequence done · current · upcoming on a
67
+ * connecting track (horizontal) or spine (vertical). The node encodes STATUS,
68
+ * not identity: a filled dot once reached, a ring with a white centre at the
69
+ * current step (pulsing when `live`), a faint ring for what's ahead, a check for
70
+ * the terminal `complete`. Drive it the compound way (`<Step status>` children —
71
+ * the label is yours to compose) or the data way (`steps` + `current`). For a
72
+ * heterogeneous event LOG with per-row icons + expandable details use `Timeline`;
73
+ * for a compact stage bar use `StepProgress`.
32
74
  */
33
75
  export function Stepper(props: StepperProps) {
34
- const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
35
- const last = steps.length - 1;
36
- const safe = Math.max(0, Math.min(current, last));
37
- const a11y = accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`;
76
+ const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
77
+
78
+ const content: ReactNode =
79
+ children ??
80
+ (steps ?? []).map((label, i) => {
81
+ const status: StepStatus = current == null ? "upcoming" : i < current ? "done" : i === current ? "current" : "upcoming";
82
+ const isCurrent = status === "current";
83
+ return (
84
+ <Step key={label} status={status}>
85
+ <Text size="xs" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "semibold" : "regular"} style={isCurrent ? { color } : undefined}>
86
+ {label}
87
+ </Text>
88
+ </Step>
89
+ );
90
+ });
91
+
92
+ const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
93
+ const statuses = items.map((c) => c.props.status);
94
+ const last = items.length - 1;
95
+ const a11y = accessibilityLabel ?? "Progress";
96
+
97
+ const positioned = items.map((child, i) =>
98
+ cloneElement(child, {
99
+ _last: i === last,
100
+ _leftFilled: i > 0 && reached(statuses[i - 1]),
101
+ _rightFilled: i < last && reached(statuses[i]),
102
+ }),
103
+ );
38
104
 
39
105
  return (
40
- <View
41
- accessibilityRole="progressbar"
42
- accessibilityLabel={a11y}
43
- style={{ flexDirection: "row" }}
44
- >
45
- {steps.map((label, i) => {
46
- const status = statusOf(i, current);
47
- const leftFilled = i <= current && i > 0;
48
- const rightFilled = i < current && i < last;
49
- const isCurrent = status === "current";
50
- return (
51
- <View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
52
- <View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
53
- <View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
54
- <StepDot status={status} color={color} />
55
- <View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
56
- </View>
57
- <Text
58
- size="xs"
59
- color={isCurrent ? "default" : "muted"}
60
- weight={isCurrent ? "semibold" : "regular"}
61
- style={isCurrent ? { color } : undefined}
62
- >
63
- {label}
64
- </Text>
65
- </View>
66
- );
67
- })}
68
- </View>
106
+ <StepperContext.Provider value={{ orientation, color, live }}>
107
+ <View accessibilityRole="progressbar" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
108
+ {positioned}
109
+ </View>
110
+ </StepperContext.Provider>
69
111
  );
70
112
  }
71
113
 
72
- function StepDot({ status, color }: { status: StepStatus; color: string }) {
73
- if (status === "done") {
74
- return <Icon name="circle-check" size={16} color={color} />;
114
+ export function Step(props: StepProps) {
115
+ const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
116
+ const { orientation, color, live } = useContext(StepperContext);
117
+
118
+ if (orientation === "horizontal") {
119
+ return (
120
+ <View style={styles.hStep}>
121
+ <View style={styles.hTrackRow}>
122
+ <View style={[styles.hTrack, { backgroundColor: _leftFilled ? color : colors.zinc[200] }]} />
123
+ <Marker status={status} color={color} live={live} />
124
+ <View style={[styles.hTrack, { backgroundColor: _rightFilled ? color : colors.zinc[200] }]} />
125
+ </View>
126
+ <View style={styles.hLabel}>{children}</View>
127
+ </View>
128
+ );
75
129
  }
130
+
131
+ const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
132
+ // Each step rises + fades into place on mount — so a streamed feed reads as
133
+ // steps APPEARING, not popping in. Animates once (on mount); a status change
134
+ // (current → done) re-renders without re-animating.
76
135
  return (
77
- <View
78
- style={{
79
- width: 16,
80
- height: 16,
81
- borderRadius: 8,
82
- backgroundColor: colors.white,
83
- borderWidth: status === "current" ? 3 : 1.5,
84
- borderColor: status === "current" ? color : colors.zinc[300],
85
- }}
86
- />
136
+ <AnimationFadeIn translateY={6}>
137
+ <View style={styles.vItem}>
138
+ <View style={styles.vSpineCol}>
139
+ <Marker status={status} color={color} live={live} />
140
+ {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
141
+ </View>
142
+ <View style={[styles.vContent, !_last ? styles.vGap : null]}>
143
+ {onPress ? (
144
+ <PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
145
+ {body}
146
+ </PressableHighlight>
147
+ ) : (
148
+ body
149
+ )}
150
+ </View>
151
+ </View>
152
+ </AnimationFadeIn>
87
153
  );
88
154
  }
155
+
156
+ // The node — status drives fill, never size. Monochrome: reached fills with ink,
157
+ // the current step is a ring around white (pulsing when live), what's ahead is a
158
+ // faint grey ring, the terminal is a checked fill.
159
+ function Marker({ status, color, live }: { status: StepStatus; color: string; live: boolean }) {
160
+ if (status === "done") {
161
+ // A passed step: filled in the accent ink with a white check.
162
+ return (
163
+ <View style={[styles.disc, { backgroundColor: color }]}>
164
+ <Icon name="check" size={10} color={colors.white} />
165
+ </View>
166
+ );
167
+ }
168
+ if (status === "complete") {
169
+ // The terminal "Done" — a blackish ring with a white centre and a black check.
170
+ return (
171
+ <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2, borderColor: colors.zinc[900] }]}>
172
+ <Icon name="check" size={10} color={colors.zinc[900]} />
173
+ </View>
174
+ );
175
+ }
176
+ if (status === "warning") {
177
+ return (
178
+ <View style={[styles.disc, { backgroundColor: solid("amber") }]}>
179
+ <View style={styles.shortBar} />
180
+ </View>
181
+ );
182
+ }
183
+ if (status === "current") {
184
+ return (
185
+ <View style={styles.discWrap}>
186
+ {live ? <Pulse color={color} /> : null}
187
+ <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2.5, borderColor: color }]} />
188
+ </View>
189
+ );
190
+ }
191
+ return <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 1.5, borderColor: colors.zinc[300] }]} />;
192
+ }
193
+
194
+ // An expanding, fading halo behind the current node — the "live" pulse.
195
+ function Pulse({ color }: { color: string }) {
196
+ const v = useRef(new Animated.Value(0)).current;
197
+ useEffect(() => {
198
+ const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: 1500, useNativeDriver: false }));
199
+ loop.start();
200
+ return () => loop.stop();
201
+ }, [v]);
202
+ const transform = [{ scale: v.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) }];
203
+ const opacity = v.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] });
204
+ return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
205
+ }
206
+
207
+ const NODE = 18;
208
+
209
+ const styles = StyleSheet.create({
210
+ hRow: { flexDirection: "row" },
211
+ hStep: { flex: 1, alignItems: "center", gap: 8 },
212
+ hTrackRow: { flexDirection: "row", alignItems: "center", width: "100%" },
213
+ hTrack: { flex: 1, height: 2 },
214
+ hLabel: { alignItems: "center" },
215
+
216
+ vItem: { flexDirection: "row", gap: 12 },
217
+ vSpineCol: { width: NODE, alignItems: "center", paddingTop: 3 },
218
+ vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
219
+ vContent: { flex: 1 },
220
+ vGap: { paddingBottom: 12 },
221
+ vPress: { borderRadius: 8, marginHorizontal: -10 },
222
+ vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 2 },
223
+ vActive: { backgroundColor: colors.zinc[100] },
224
+
225
+ discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
226
+ disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
227
+ pulse: { position: "absolute", width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2, pointerEvents: "none" },
228
+ shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.white },
229
+ });
package/src/step_list.tsx DELETED
@@ -1,128 +0,0 @@
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
- });