@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 +2 -1
- package/src/segmented_control.tsx +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "3.
|
|
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
|
+
});
|