@compsych/mobile-ui 1.0.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/README.md +1015 -0
- package/package.json +26 -0
- package/src/Alert.tsx +372 -0
- package/src/Avatar.tsx +255 -0
- package/src/Badge.tsx +189 -0
- package/src/BodyText.tsx +62 -0
- package/src/Breadcrumb.tsx +192 -0
- package/src/Button.tsx +228 -0
- package/src/Card.tsx +152 -0
- package/src/Checkbox.tsx +182 -0
- package/src/Chip.tsx +291 -0
- package/src/Divider.tsx +60 -0
- package/src/EmptyState.tsx +364 -0
- package/src/HeaderText.tsx +69 -0
- package/src/Input.tsx +228 -0
- package/src/Pagination.tsx +353 -0
- package/src/ProgressTracker.tsx +160 -0
- package/src/RadioButton.tsx +177 -0
- package/src/Slider.tsx +347 -0
- package/src/Switch.tsx +183 -0
- package/src/Tooltip.tsx +214 -0
- package/src/index.ts +62 -0
- package/src/tokens.ts +121 -0
package/src/Slider.tsx
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
LayoutChangeEvent,
|
|
5
|
+
PanResponder,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { sys } from './tokens';
|
|
11
|
+
|
|
12
|
+
export interface SliderProps {
|
|
13
|
+
/** Controlled value */
|
|
14
|
+
value?: number;
|
|
15
|
+
/** Default value for uncontrolled usage (default: min) */
|
|
16
|
+
defaultValue?: number;
|
|
17
|
+
min?: number;
|
|
18
|
+
max?: number;
|
|
19
|
+
/** Snapping interval (default: 1) */
|
|
20
|
+
step?: number;
|
|
21
|
+
/** Called continuously while dragging */
|
|
22
|
+
onValueChange?: (value: number) => void;
|
|
23
|
+
/** Called when the user releases the thumb */
|
|
24
|
+
onSlidingComplete?: (value: number) => void;
|
|
25
|
+
/** Label rendered above the track */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Show min/max value labels on either side of the track */
|
|
28
|
+
showMinMax?: boolean;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
accessibilityLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
|
|
34
|
+
|
|
35
|
+
// ── Layout constants ──────────────────────────────────────────────────────────
|
|
36
|
+
const TRACK_HEIGHT = 8;
|
|
37
|
+
const THUMB_SIZE = 24;
|
|
38
|
+
const THUMB_HALF = THUMB_SIZE / 2; // 12
|
|
39
|
+
const HIT_HEIGHT = 48; // symbol height from Figma — contains track + thumb
|
|
40
|
+
// Vertical offsets within the 48px container
|
|
41
|
+
const TRACK_TOP = (HIT_HEIGHT - TRACK_HEIGHT) / 2; // 20
|
|
42
|
+
const THUMB_TOP = (HIT_HEIGHT - THUMB_SIZE) / 2; // 12
|
|
43
|
+
|
|
44
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
47
|
+
return Math.max(lo, Math.min(hi, v));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function Slider({
|
|
53
|
+
value: valueProp,
|
|
54
|
+
defaultValue,
|
|
55
|
+
min = 0,
|
|
56
|
+
max = 100,
|
|
57
|
+
step = 1,
|
|
58
|
+
onValueChange,
|
|
59
|
+
onSlidingComplete,
|
|
60
|
+
label,
|
|
61
|
+
showMinMax = true,
|
|
62
|
+
disabled = false,
|
|
63
|
+
accessibilityLabel,
|
|
64
|
+
}: SliderProps) {
|
|
65
|
+
const isControlled = valueProp !== undefined;
|
|
66
|
+
const [internalValue, setInternalValue] = useState<number>(
|
|
67
|
+
defaultValue ?? min,
|
|
68
|
+
);
|
|
69
|
+
const displayValue = isControlled ? valueProp! : internalValue;
|
|
70
|
+
|
|
71
|
+
const [focused, setFocused] = useState(false);
|
|
72
|
+
const [pressed, setPressed] = useState(false);
|
|
73
|
+
|
|
74
|
+
// ── Track width (measured on layout) ────────────────────────────────────────
|
|
75
|
+
const trackWidthRef = useRef(0);
|
|
76
|
+
const [trackWidth, setTrackWidth] = useState(0);
|
|
77
|
+
|
|
78
|
+
const handleTrackLayout = useCallback((e: LayoutChangeEvent) => {
|
|
79
|
+
const w = e.nativeEvent.layout.width;
|
|
80
|
+
trackWidthRef.current = w;
|
|
81
|
+
setTrackWidth(w);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// Keep a ref to the latest displayValue so PanResponder can read it
|
|
85
|
+
const displayValueRef = useRef(displayValue);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
displayValueRef.current = displayValue;
|
|
88
|
+
}, [displayValue]);
|
|
89
|
+
|
|
90
|
+
// ── Value conversion ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function fractionToValue(frac: number): number {
|
|
93
|
+
const raw = min + clamp(frac, 0, 1) * (max - min);
|
|
94
|
+
const stepped = Math.round(raw / step) * step;
|
|
95
|
+
return clamp(stepped, min, max);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function valueToFraction(v: number): number {
|
|
99
|
+
return (v - min) / (max - min);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function commitValue(v: number, isFinal: boolean) {
|
|
103
|
+
if (!isControlled) setInternalValue(v);
|
|
104
|
+
onValueChange?.(v);
|
|
105
|
+
if (isFinal) onSlidingComplete?.(v);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── PanResponder ─────────────────────────────────────────────────────────────
|
|
109
|
+
const panStartFraction = useRef(0);
|
|
110
|
+
|
|
111
|
+
const panResponder = useRef(
|
|
112
|
+
PanResponder.create({
|
|
113
|
+
onStartShouldSetPanResponder: () => !disabled && trackWidthRef.current > 0,
|
|
114
|
+
onMoveShouldSetPanResponder: () => !disabled && trackWidthRef.current > 0,
|
|
115
|
+
// Don't let parent ScrollView steal the gesture once we've started
|
|
116
|
+
onPanResponderTerminationRequest: () => false,
|
|
117
|
+
|
|
118
|
+
onPanResponderGrant: (evt) => {
|
|
119
|
+
setPressed(true);
|
|
120
|
+
// Jump thumb to the tapped position on the track
|
|
121
|
+
const tapped = clamp(evt.nativeEvent.locationX, 0, trackWidthRef.current);
|
|
122
|
+
panStartFraction.current = tapped / trackWidthRef.current;
|
|
123
|
+
const v = fractionToValue(panStartFraction.current);
|
|
124
|
+
commitValue(v, false);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
onPanResponderMove: (_, gs) => {
|
|
128
|
+
const newFrac = clamp(
|
|
129
|
+
panStartFraction.current + gs.dx / trackWidthRef.current,
|
|
130
|
+
0, 1,
|
|
131
|
+
);
|
|
132
|
+
const v = fractionToValue(newFrac);
|
|
133
|
+
commitValue(v, false);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
onPanResponderRelease: (_, gs) => {
|
|
137
|
+
setPressed(false);
|
|
138
|
+
const newFrac = clamp(
|
|
139
|
+
panStartFraction.current + gs.dx / trackWidthRef.current,
|
|
140
|
+
0, 1,
|
|
141
|
+
);
|
|
142
|
+
commitValue(fractionToValue(newFrac), true);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
onPanResponderTerminate: () => setPressed(false),
|
|
146
|
+
}),
|
|
147
|
+
).current;
|
|
148
|
+
|
|
149
|
+
// ── Derived geometry ──────────────────────────────────────────────────────────
|
|
150
|
+
const fraction = trackWidth > 0 ? valueToFraction(displayValue) : 0;
|
|
151
|
+
const thumbCenterX = fraction * trackWidth;
|
|
152
|
+
const thumbLeft = thumbCenterX - THUMB_HALF;
|
|
153
|
+
const fillWidth = thumbCenterX;
|
|
154
|
+
|
|
155
|
+
// ── Thumb shadow — lv3 when pressed/hovered, lv1 otherwise ──────────────────
|
|
156
|
+
const thumbShadow = pressed
|
|
157
|
+
? {
|
|
158
|
+
shadowColor: '#000',
|
|
159
|
+
shadowOffset: { width: 0, height: 4 },
|
|
160
|
+
shadowOpacity: 0.16,
|
|
161
|
+
shadowRadius: 16,
|
|
162
|
+
elevation: 3,
|
|
163
|
+
}
|
|
164
|
+
: {
|
|
165
|
+
shadowColor: '#000',
|
|
166
|
+
shadowOffset: { width: 0, height: 2 },
|
|
167
|
+
shadowOpacity: 0.06,
|
|
168
|
+
shadowRadius: 8,
|
|
169
|
+
elevation: 1,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
173
|
+
return (
|
|
174
|
+
<View
|
|
175
|
+
style={styles.root}
|
|
176
|
+
accessible
|
|
177
|
+
accessibilityRole="adjustable"
|
|
178
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
179
|
+
accessibilityValue={{
|
|
180
|
+
min,
|
|
181
|
+
max,
|
|
182
|
+
now: displayValue,
|
|
183
|
+
}}
|
|
184
|
+
onAccessibilityAction={(e) => {
|
|
185
|
+
if (disabled) return;
|
|
186
|
+
const delta = e.nativeEvent.actionName === 'increment' ? step : -step;
|
|
187
|
+
commitValue(clamp(displayValue + delta, min, max), true);
|
|
188
|
+
}}
|
|
189
|
+
onFocus={() => setFocused(true)}
|
|
190
|
+
onBlur={() => setFocused(false)}
|
|
191
|
+
>
|
|
192
|
+
{/* ── Optional label ────────────────────────────────────────────────── */}
|
|
193
|
+
{label && (
|
|
194
|
+
<Text style={styles.label}>{label}</Text>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* ── Slider row ────────────────────────────────────────────────────── */}
|
|
198
|
+
<View
|
|
199
|
+
style={[
|
|
200
|
+
styles.row,
|
|
201
|
+
disabled && styles.rowDisabled,
|
|
202
|
+
]}
|
|
203
|
+
>
|
|
204
|
+
{/* Min label */}
|
|
205
|
+
{showMinMax && (
|
|
206
|
+
<Text style={styles.rangeLabel}>{min}</Text>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* ── Track container — the gesture zone ──────────────────────────── */}
|
|
210
|
+
<View
|
|
211
|
+
style={styles.trackContainer}
|
|
212
|
+
onLayout={handleTrackLayout}
|
|
213
|
+
{...panResponder.panHandlers}
|
|
214
|
+
>
|
|
215
|
+
{/* Track background (unfilled — full width) */}
|
|
216
|
+
<View style={styles.trackBg} />
|
|
217
|
+
|
|
218
|
+
{/* Track fill (filled — up to thumb center) */}
|
|
219
|
+
<View
|
|
220
|
+
style={[
|
|
221
|
+
styles.trackFill,
|
|
222
|
+
{ width: fillWidth },
|
|
223
|
+
]}
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
{/* ── Thumb ─────────────────────────────────────────────────────── */}
|
|
227
|
+
<View
|
|
228
|
+
style={[
|
|
229
|
+
styles.thumb,
|
|
230
|
+
{ left: thumbLeft },
|
|
231
|
+
thumbShadow,
|
|
232
|
+
]}
|
|
233
|
+
>
|
|
234
|
+
{/* Focus ring — 1px outside thumb (26×26 centered over 24×24) */}
|
|
235
|
+
{focused && (
|
|
236
|
+
<View
|
|
237
|
+
style={[
|
|
238
|
+
styles.focusRing,
|
|
239
|
+
{
|
|
240
|
+
borderColor: cr.addOn.primaryFixed.sysOnPrimaryFixedVariant,
|
|
241
|
+
borderWidth: dim.borderWidth.sysStrokeMedium,
|
|
242
|
+
},
|
|
243
|
+
]}
|
|
244
|
+
pointerEvents="none"
|
|
245
|
+
/>
|
|
246
|
+
)}
|
|
247
|
+
</View>
|
|
248
|
+
</View>
|
|
249
|
+
|
|
250
|
+
{/* Max label */}
|
|
251
|
+
{showMinMax && (
|
|
252
|
+
<Text style={styles.rangeLabel}>{max}</Text>
|
|
253
|
+
)}
|
|
254
|
+
</View>
|
|
255
|
+
</View>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const styles = StyleSheet.create({
|
|
260
|
+
root: {
|
|
261
|
+
gap: dim.spacing.padding.sysPadding8,
|
|
262
|
+
alignSelf: 'stretch',
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// ── Label ─────────────────────────────────────────────────────────────────
|
|
266
|
+
label: {
|
|
267
|
+
color: cr.surface.surface.sysOnSurface,
|
|
268
|
+
fontSize: ts.labelMedium.sysFontSize,
|
|
269
|
+
lineHeight: ts.labelMedium.sysLineHeight,
|
|
270
|
+
letterSpacing: ts.labelMedium.sysTracking,
|
|
271
|
+
fontWeight: '400',
|
|
272
|
+
includeFontPadding: false,
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// ── Row ───────────────────────────────────────────────────────────────────
|
|
276
|
+
row: {
|
|
277
|
+
flexDirection: 'row',
|
|
278
|
+
alignItems: 'center',
|
|
279
|
+
gap: dim.spacing.padding.sysPadding8,
|
|
280
|
+
},
|
|
281
|
+
rowDisabled: {
|
|
282
|
+
opacity: 0.48,
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
// ── Range labels ──────────────────────────────────────────────────────────
|
|
286
|
+
rangeLabel: {
|
|
287
|
+
color: cr.surface.surface.sysOnSurfaceVariant,
|
|
288
|
+
fontSize: ts.bodySmall.sysFontSize,
|
|
289
|
+
lineHeight: ts.bodySmall.sysLineHeight,
|
|
290
|
+
fontWeight: '400',
|
|
291
|
+
includeFontPadding: false,
|
|
292
|
+
flexShrink: 0,
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
// ── Track container ───────────────────────────────────────────────────────
|
|
296
|
+
trackContainer: {
|
|
297
|
+
flex: 1,
|
|
298
|
+
height: HIT_HEIGHT,
|
|
299
|
+
// Overflow visible so the thumb (which extends ±12px from track centre)
|
|
300
|
+
// and its focus ring are not clipped at the extremes.
|
|
301
|
+
overflow: 'visible',
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// ── Track background ──────────────────────────────────────────────────────
|
|
305
|
+
trackBg: {
|
|
306
|
+
position: 'absolute',
|
|
307
|
+
top: TRACK_TOP,
|
|
308
|
+
left: 0,
|
|
309
|
+
right: 0,
|
|
310
|
+
height: TRACK_HEIGHT,
|
|
311
|
+
borderRadius: 9999,
|
|
312
|
+
backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
// ── Track fill ────────────────────────────────────────────────────────────
|
|
316
|
+
trackFill: {
|
|
317
|
+
position: 'absolute',
|
|
318
|
+
top: TRACK_TOP,
|
|
319
|
+
left: 0,
|
|
320
|
+
height: TRACK_HEIGHT,
|
|
321
|
+
borderRadius: 9999,
|
|
322
|
+
backgroundColor: cr.accent.primary.sysPrimary,
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// ── Thumb ─────────────────────────────────────────────────────────────────
|
|
326
|
+
thumb: {
|
|
327
|
+
position: 'absolute',
|
|
328
|
+
top: THUMB_TOP,
|
|
329
|
+
width: THUMB_SIZE,
|
|
330
|
+
height: THUMB_SIZE,
|
|
331
|
+
borderRadius: 9999,
|
|
332
|
+
backgroundColor: cr.surface.surface.sysSurface,
|
|
333
|
+
// Focus ring is absolutely positioned inside the thumb
|
|
334
|
+
alignItems: 'center',
|
|
335
|
+
justifyContent: 'center',
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// ── Focus ring: 1px outside the 24×24 thumb → 26×26 ─────────────────────
|
|
339
|
+
focusRing: {
|
|
340
|
+
position: 'absolute',
|
|
341
|
+
top: -2,
|
|
342
|
+
left: -2,
|
|
343
|
+
right: -2,
|
|
344
|
+
bottom: -2,
|
|
345
|
+
borderRadius: 9999,
|
|
346
|
+
},
|
|
347
|
+
});
|
package/src/Switch.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
View,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
import { sys } from './tokens';
|
|
9
|
+
|
|
10
|
+
export interface SwitchProps {
|
|
11
|
+
/** Controlled value. Omit to use internal state. */
|
|
12
|
+
value?: boolean;
|
|
13
|
+
/** Default value for uncontrolled usage */
|
|
14
|
+
defaultValue?: boolean;
|
|
15
|
+
/** Called when the user toggles the switch */
|
|
16
|
+
onValueChange?: (value: boolean) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
accessibilityLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { colorRoles: cr, dimensions: dim } = sys;
|
|
22
|
+
|
|
23
|
+
// ── Layout constants ──────────────────────────────────────────────────────────
|
|
24
|
+
const TRACK_W = 56;
|
|
25
|
+
const TRACK_H = 32;
|
|
26
|
+
const THUMB_SIZE = 24;
|
|
27
|
+
const TRACK_PADDING = dim.spacing.padding.sysPadding4;
|
|
28
|
+
|
|
29
|
+
// Thumb translateX: off=left edge, on=right edge
|
|
30
|
+
const THUMB_OFF = TRACK_PADDING; // 4
|
|
31
|
+
const THUMB_ON = TRACK_W - TRACK_PADDING - THUMB_SIZE; // 56 - 4 - 24 = 28
|
|
32
|
+
|
|
33
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function Switch({
|
|
36
|
+
value: valueProp,
|
|
37
|
+
defaultValue = false,
|
|
38
|
+
onValueChange,
|
|
39
|
+
disabled = false,
|
|
40
|
+
accessibilityLabel,
|
|
41
|
+
}: SwitchProps) {
|
|
42
|
+
// Controlled vs uncontrolled
|
|
43
|
+
const isControlled = valueProp !== undefined;
|
|
44
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
45
|
+
const toggled = isControlled ? valueProp! : internalValue;
|
|
46
|
+
|
|
47
|
+
const [focused, setFocused] = useState(false);
|
|
48
|
+
|
|
49
|
+
// ── Animation ──────────────────────────────────────────────────────────────
|
|
50
|
+
const thumbAnim = useRef(new Animated.Value(toggled ? THUMB_ON : THUMB_OFF)).current;
|
|
51
|
+
const bgAnim = useRef(new Animated.Value(toggled ? 1 : 0)).current;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
Animated.parallel([
|
|
55
|
+
Animated.timing(thumbAnim, {
|
|
56
|
+
toValue: toggled ? THUMB_ON : THUMB_OFF,
|
|
57
|
+
duration: 150,
|
|
58
|
+
useNativeDriver: true,
|
|
59
|
+
}),
|
|
60
|
+
Animated.timing(bgAnim, {
|
|
61
|
+
toValue: toggled ? 1 : 0,
|
|
62
|
+
duration: 150,
|
|
63
|
+
useNativeDriver: false, // backgroundColor not supported by native driver
|
|
64
|
+
}),
|
|
65
|
+
]).start();
|
|
66
|
+
}, [toggled]);
|
|
67
|
+
|
|
68
|
+
// Interpolate background color
|
|
69
|
+
const trackBg = bgAnim.interpolate({
|
|
70
|
+
inputRange: [0, 1],
|
|
71
|
+
outputRange: [
|
|
72
|
+
cr.surface.surfaceContainer.sysSurfaceContainerHighest,
|
|
73
|
+
cr.accent.primary.sysPrimary,
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Thumb color: on=sysOnPrimary, off=sysSurface
|
|
78
|
+
const thumbBg = bgAnim.interpolate({
|
|
79
|
+
inputRange: [0, 1],
|
|
80
|
+
outputRange: [cr.surface.surface.sysSurface, cr.accent.primary.sysOnPrimary],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Handler ────────────────────────────────────────────────────────────────
|
|
84
|
+
function handlePress() {
|
|
85
|
+
if (disabled) return;
|
|
86
|
+
const next = !toggled;
|
|
87
|
+
if (!isControlled) setInternalValue(next);
|
|
88
|
+
onValueChange?.(next);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Pressable
|
|
93
|
+
onPress={handlePress}
|
|
94
|
+
onFocus={() => setFocused(true)}
|
|
95
|
+
onBlur={() => setFocused(false)}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
accessibilityRole="switch"
|
|
98
|
+
accessibilityLabel={accessibilityLabel}
|
|
99
|
+
accessibilityState={{ checked: toggled, disabled }}
|
|
100
|
+
style={styles.pressable}
|
|
101
|
+
>
|
|
102
|
+
{/* ── Focus ring (outside track by 1px) ─────────────────────────────── */}
|
|
103
|
+
{focused && (
|
|
104
|
+
<View
|
|
105
|
+
style={[
|
|
106
|
+
styles.focusRing,
|
|
107
|
+
{
|
|
108
|
+
borderColor: cr.addOn.primaryFixed.sysOnPrimaryFixedVariant,
|
|
109
|
+
borderWidth: dim.borderWidth.sysStrokeMedium,
|
|
110
|
+
},
|
|
111
|
+
]}
|
|
112
|
+
pointerEvents="none"
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* ── Track ─────────────────────────────────────────────────────────── */}
|
|
117
|
+
<Animated.View
|
|
118
|
+
style={[
|
|
119
|
+
styles.track,
|
|
120
|
+
{ backgroundColor: trackBg },
|
|
121
|
+
disabled && styles.trackDisabled,
|
|
122
|
+
]}
|
|
123
|
+
>
|
|
124
|
+
{/* ── Thumb ───────────────────────────────────────────────────────── */}
|
|
125
|
+
<Animated.View
|
|
126
|
+
style={[
|
|
127
|
+
styles.thumb,
|
|
128
|
+
{
|
|
129
|
+
backgroundColor: thumbBg,
|
|
130
|
+
transform: [{ translateX: thumbAnim }],
|
|
131
|
+
},
|
|
132
|
+
disabled && styles.thumbDisabled,
|
|
133
|
+
]}
|
|
134
|
+
/>
|
|
135
|
+
</Animated.View>
|
|
136
|
+
</Pressable>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const styles = StyleSheet.create({
|
|
141
|
+
pressable: {
|
|
142
|
+
// Sized to the track; focus ring is absolutely overlaid
|
|
143
|
+
width: TRACK_W,
|
|
144
|
+
height: TRACK_H,
|
|
145
|
+
alignSelf: 'flex-start',
|
|
146
|
+
},
|
|
147
|
+
track: {
|
|
148
|
+
width: TRACK_W,
|
|
149
|
+
height: TRACK_H,
|
|
150
|
+
borderRadius: 9999,
|
|
151
|
+
// Thumb is absolutely positioned inside the track
|
|
152
|
+
position: 'relative',
|
|
153
|
+
},
|
|
154
|
+
trackDisabled: {
|
|
155
|
+
opacity: 0.48,
|
|
156
|
+
},
|
|
157
|
+
thumb: {
|
|
158
|
+
position: 'absolute',
|
|
159
|
+
top: TRACK_PADDING,
|
|
160
|
+
left: 0, // translateX drives actual X position
|
|
161
|
+
width: THUMB_SIZE,
|
|
162
|
+
height: THUMB_SIZE,
|
|
163
|
+
borderRadius: 9999,
|
|
164
|
+
// lv2 shadow
|
|
165
|
+
shadowColor: '#000',
|
|
166
|
+
shadowOffset: { width: 0, height: 2 },
|
|
167
|
+
shadowOpacity: 0.08,
|
|
168
|
+
shadowRadius: 4,
|
|
169
|
+
elevation: 2,
|
|
170
|
+
},
|
|
171
|
+
thumbDisabled: {
|
|
172
|
+
opacity: 0.64,
|
|
173
|
+
},
|
|
174
|
+
// Focus ring: 1px outside the track on all sides
|
|
175
|
+
focusRing: {
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
top: -2, // 1px gap + sysStrokeMedium (1.5px) ≈ 2px
|
|
178
|
+
left: -2,
|
|
179
|
+
right: -2,
|
|
180
|
+
bottom: -2,
|
|
181
|
+
borderRadius: 9999,
|
|
182
|
+
},
|
|
183
|
+
});
|