@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/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
+ });