@lotics/ui 3.0.0 → 3.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -127,6 +127,7 @@
127
127
  "./stepper": "./src/stepper.tsx",
128
128
  "./step_progress": "./src/step_progress.tsx",
129
129
  "./tabs": "./src/tabs.tsx",
130
+ "./segmented_control": "./src/segmented_control.tsx",
130
131
  "./auto_sizer": "./src/auto_sizer.tsx",
131
132
  "./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
132
133
  "./group_avatar": "./src/group_avatar.tsx",
@@ -0,0 +1,201 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+ import { Text } from "./text";
5
+ import { colors, withAlpha } from "./colors";
6
+
7
+ export interface SegmentOption<T extends string> {
8
+ label: string;
9
+ value: T;
10
+ testID?: string;
11
+ }
12
+
13
+ interface SegmentedControlProps<T extends string> {
14
+ /** Accessible name of the group (it renders no visible label of its own). */
15
+ accessibilityLabel: string;
16
+ /** 2–4 mutually-exclusive peers. Above ~4, use Picker — segments stop fitting. */
17
+ options: SegmentOption<T>[];
18
+ value: T;
19
+ onValueChange: (value: T) => void;
20
+ /** Disables the whole control (e.g. while a mode-specific action runs). */
21
+ disabled?: boolean;
22
+ /** The track hugs its content by default (`alignSelf: flex-start`); pass
23
+ * `{ alignSelf: "stretch" }` to fill the parent's width. */
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ /**
28
+ * A segmented control: 2–4 visible peer segments in an inset track, the
29
+ * selected one raised as a white card. Semantically a **radiogroup** — a
30
+ * single choice among peers, all visible — NOT a tablist (no panels) and not
31
+ * a Switch (not on/off). Reach for it on a binary/few-way *mode* switch where
32
+ * both options should be one-tap and on screen (a generator mode, a list/grid
33
+ * view, a day/week/month range). Above ~4 options, use `Picker`.
34
+ *
35
+ * Keyboard follows the WAI-ARIA radio pattern: roving tabindex (only the
36
+ * selected segment is a tab stop), arrows move focus AND select (wrapping),
37
+ * Space/Enter select the focused segment.
38
+ */
39
+ export function SegmentedControl<T extends string>(props: SegmentedControlProps<T>) {
40
+ const { accessibilityLabel, options, value, onValueChange, disabled = false, style } = props;
41
+ const segmentRefs = useRef<Array<View | null>>([]);
42
+
43
+ const select = useCallback(
44
+ (index: number) => {
45
+ const option = options[index];
46
+ if (!option || option.value === value) return;
47
+ onValueChange(option.value);
48
+ },
49
+ [options, value, onValueChange],
50
+ );
51
+
52
+ // Arrows wrap and select per the radio pattern; Space/Enter select the
53
+ // focused segment. Focus follows selection so the new segment stays reachable.
54
+ const handleKeyDown = useCallback(
55
+ (event: { key: string; preventDefault?: () => void }, index: number) => {
56
+ if (disabled) return;
57
+ const last = options.length - 1;
58
+ let next = index;
59
+ switch (event.key) {
60
+ case "ArrowRight":
61
+ case "ArrowDown":
62
+ next = index === last ? 0 : index + 1;
63
+ break;
64
+ case "ArrowLeft":
65
+ case "ArrowUp":
66
+ next = index === 0 ? last : index - 1;
67
+ break;
68
+ case "Enter":
69
+ case " ":
70
+ event.preventDefault?.();
71
+ select(index);
72
+ return;
73
+ default:
74
+ return;
75
+ }
76
+ event.preventDefault?.();
77
+ onValueChange(options[next].value);
78
+ segmentRefs.current[next]?.focus();
79
+ },
80
+ [disabled, options, onValueChange, select],
81
+ );
82
+
83
+ // Roving tabindex: the selected segment is the tab stop. When `value` matches
84
+ // no option, the first segment is the fallback so the group stays reachable.
85
+ const selectedIndex = options.findIndex((option) => option.value === value);
86
+ const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
87
+
88
+ return (
89
+ <View
90
+ style={[styles.track, disabled && styles.trackDisabled, style]}
91
+ accessibilityRole="radiogroup"
92
+ aria-label={accessibilityLabel}
93
+ aria-disabled={disabled}
94
+ >
95
+ {options.map((option, index) => (
96
+ <Segment
97
+ ref={(node: View | null) => {
98
+ segmentRefs.current[index] = node;
99
+ }}
100
+ key={option.value}
101
+ option={option}
102
+ selected={option.value === value}
103
+ isTabStop={index === tabStopIndex}
104
+ disabled={disabled}
105
+ onPress={() => select(index)}
106
+ onKeyDown={(event) => handleKeyDown(event, index)}
107
+ />
108
+ ))}
109
+ </View>
110
+ );
111
+ }
112
+
113
+ interface SegmentProps<T extends string> {
114
+ ref: (node: View | null) => void;
115
+ option: SegmentOption<T>;
116
+ selected: boolean;
117
+ isTabStop: boolean;
118
+ disabled: boolean;
119
+ onPress: () => void;
120
+ onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
121
+ }
122
+
123
+ function Segment<T extends string>(props: SegmentProps<T>) {
124
+ const { ref, option, selected, isTabStop, disabled, onPress, onKeyDown } = props;
125
+
126
+ return (
127
+ <PressableHighlight
128
+ ref={ref}
129
+ // Own the per-state background: the track is zinc-100, so an unselected
130
+ // segment hovering must lift TOWARD the selected white (a half-white wash),
131
+ // not to PressableHighlight's default zinc-100 (invisible on the track).
132
+ style={(state) => {
133
+ const hovered = (state as { hovered?: boolean }).hovered;
134
+ return [
135
+ styles.segment,
136
+ selected
137
+ ? styles.segmentSelected
138
+ : {
139
+ backgroundColor: state.pressed
140
+ ? colors.white
141
+ : hovered
142
+ ? withAlpha(colors.white, 0.5)
143
+ : "transparent",
144
+ },
145
+ ];
146
+ }}
147
+ onPress={disabled ? undefined : onPress}
148
+ onKeyDown={onKeyDown}
149
+ testID={option.testID}
150
+ accessibilityRole="radio"
151
+ accessibilityLabel={option.label}
152
+ aria-checked={selected}
153
+ aria-disabled={disabled}
154
+ // Roving tabindex: only the selected segment (or the fallback) is a tab
155
+ // stop; the rest are reached with arrow keys.
156
+ focusable={isTabStop && !disabled}
157
+ >
158
+ {(state) => {
159
+ const hovered = (state as { hovered?: boolean }).hovered;
160
+ return (
161
+ <Text
162
+ size="sm"
163
+ weight={selected ? "medium" : "regular"}
164
+ color={selected || hovered ? "zinc-900" : "zinc-700"}
165
+ numberOfLines={1}
166
+ userSelect="none"
167
+ >
168
+ {option.label}
169
+ </Text>
170
+ );
171
+ }}
172
+ </PressableHighlight>
173
+ );
174
+ }
175
+
176
+ const styles = StyleSheet.create({
177
+ track: {
178
+ flexDirection: "row",
179
+ alignSelf: "flex-start",
180
+ gap: 2,
181
+ padding: 3,
182
+ borderRadius: 10,
183
+ backgroundColor: colors.zinc["100"],
184
+ },
185
+ trackDisabled: {
186
+ opacity: 0.55,
187
+ },
188
+ segment: {
189
+ flex: 1,
190
+ minHeight: 30,
191
+ paddingVertical: 6,
192
+ paddingHorizontal: 14,
193
+ borderRadius: 7,
194
+ alignItems: "center",
195
+ justifyContent: "center",
196
+ },
197
+ segmentSelected: {
198
+ backgroundColor: colors.white,
199
+ boxShadow: colors.border_shadow,
200
+ },
201
+ });