@medway-ui/native 1.8.0 → 1.8.2

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.
@@ -1,57 +1,109 @@
1
- import { useState, useEffect, type ReactNode } from "react";
2
- import { View, type ViewStyle } from "react-native";
1
+ import {
2
+ cloneElement,
3
+ isValidElement,
4
+ memo,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
13
+ import { cssInterop } from "nativewind";
3
14
  import * as SwitchPrimitive from "@rn-primitives/switch";
4
15
  import Animated, {
5
- useSharedValue,
16
+ Easing,
17
+ interpolateColor,
6
18
  useAnimatedStyle,
19
+ useSharedValue,
7
20
  withTiming,
8
- Easing,
21
+ type SharedValue,
9
22
  } from "react-native-reanimated";
10
23
  import { cva, type VariantProps } from "class-variance-authority";
11
24
  import { cn } from "@/lib/utils";
12
25
  import { getMinTouchHitSlop } from "@/lib/touch-target";
13
- import { useThemeColor } from "@/hooks/useThemeColor";
26
+ import { useThemeColor, withAlpha } from "@/hooks/useThemeColor";
14
27
 
15
- interface SwitchProps
16
- extends Omit<
17
- SwitchPrimitive.RootProps,
18
- "checked" | "onCheckedChange" | "children"
19
- >,
20
- VariantProps<typeof switchVariants> {
21
- checked?: boolean;
22
- defaultChecked?: boolean;
23
- onCheckedChange?: (checked: boolean) => void;
24
- checkedIcon?: ReactNode;
25
- uncheckedIcon?: ReactNode;
26
- }
28
+ // `@rn-primitives/switch` renders a plain Pressable — cssInterop makes `className` work.
29
+ const StyledSwitchRoot = cssInterop(SwitchPrimitive.Root, {
30
+ className: "style",
31
+ });
32
+
33
+ type SwitchSize = "sm" | "md" | "lg";
27
34
 
28
- const THUMB_SIZES: Record<"sm" | "md" | "lg", number> = {
35
+ type SwitchIconProps = {
36
+ color?: string;
37
+ width?: number;
38
+ height?: number;
39
+ };
40
+
41
+ const BORDER_WIDTH = 2;
42
+ /** Gap between thumb and inner edge of the track border (web `translate-x-0.5`). */
43
+ const THUMB_INSET = 2;
44
+ const CENTER: ViewStyle = { alignItems: "center", justifyContent: "center" };
45
+
46
+ const THUMB_SIZES: Record<SwitchSize, number> = {
29
47
  sm: 8,
30
48
  md: 12,
31
49
  lg: 16,
32
50
  };
33
51
 
34
- const THUMB_TRANSLATE: Record<"sm" | "md" | "lg", number> = {
35
- sm: 11,
36
- md: 15,
37
- lg: 19,
52
+ const ICON_SIZES: Record<SwitchSize, number> = {
53
+ sm: 8,
54
+ md: 12,
55
+ lg: 14,
38
56
  };
39
57
 
40
- const SWITCH_DIMENSIONS: Record<"sm" | "md" | "lg", { width: number; height: number }> = {
41
- sm: { width: 24, height: 16 },
42
- md: { width: 32, height: 20 },
43
- lg: { width: 40, height: 24 },
44
- };
58
+ /** Same scale as web `h-4 w-6` / `h-5 w-8` / `h-6 w-10` at 16px rem. */
59
+ const SWITCH_DIMENSIONS: Record<SwitchSize, { width: number; height: number }> =
60
+ {
61
+ sm: { width: 24, height: 16 },
62
+ md: { width: 32, height: 20 },
63
+ lg: { width: 40, height: 24 },
64
+ };
65
+
66
+ /**
67
+ * Thumb is a sibling of the bordered track (not a child of it), so offsets are
68
+ * from the root's outer box. That avoids Yoga content-box quirks where absolute
69
+ * children of a bordered view shift the knob down / past the right edge.
70
+ *
71
+ * off = border + inset
72
+ * on = width - border - inset - thumb
73
+ * on+disabled keeps a 1px tighter right inset, matching web.
74
+ */
75
+ function getThumbTranslateX(
76
+ trackWidth: number,
77
+ thumbSize: number,
78
+ checked: boolean,
79
+ disabled: boolean
80
+ ): number {
81
+ const off = BORDER_WIDTH + THUMB_INSET;
82
+ if (!checked) return off;
83
+ const on = trackWidth - BORDER_WIDTH - THUMB_INSET - thumbSize;
84
+ return disabled ? on - 1 : on;
85
+ }
86
+
87
+ /**
88
+ * Even curve. A strong ease-out spends the last frames crawling, which reads
89
+ * as the thumb catching. Track and thumb colors ride the same curve.
90
+ */
91
+ const THUMB_MOTION = {
92
+ duration: 250,
93
+ easing: Easing.inOut(Easing.cubic),
94
+ } as const;
45
95
 
46
- const switchVariants = cva("rounded-full border-2 items-center shrink-0", {
96
+ // Px, not `h-6 w-10`: NativeWind resolves rem as 14px on native, which shrinks
97
+ // the track below SWITCH_DIMENSIONS and pushes the thumb down and out the right.
98
+ const switchVariants = cva("shrink-0", {
47
99
  variants: {
48
100
  variant: {
49
101
  regular: "",
50
102
  },
51
103
  size: {
52
- sm: "h-4 w-6",
53
- md: "h-5 w-8",
54
- lg: "h-6 w-10",
104
+ sm: "h-[16px] w-[24px]",
105
+ md: "h-[20px] w-[32px]",
106
+ lg: "h-[24px] w-[40px]",
55
107
  },
56
108
  },
57
109
  defaultVariants: {
@@ -60,119 +112,255 @@ const switchVariants = cva("rounded-full border-2 items-center shrink-0", {
60
112
  },
61
113
  });
62
114
 
63
- function Switch({
115
+ interface SwitchProps
116
+ extends Omit<
117
+ SwitchPrimitive.RootProps,
118
+ "checked" | "onCheckedChange" | "children" | "style"
119
+ >,
120
+ VariantProps<typeof switchVariants> {
121
+ style?: StyleProp<ViewStyle>;
122
+ checked?: boolean;
123
+ defaultChecked?: boolean;
124
+ onCheckedChange?: (checked: boolean) => void;
125
+ checkedIcon?: ReactNode;
126
+ uncheckedIcon?: ReactNode;
127
+ }
128
+
129
+ function renderThumbIcon(icon: ReactNode, color: string, size: number) {
130
+ if (!isValidElement<SwitchIconProps>(icon)) return icon;
131
+
132
+ return cloneElement(icon as ReactElement<SwitchIconProps>, {
133
+ color: icon.props.color ?? color,
134
+ width: icon.props.width ?? size,
135
+ height: icon.props.height ?? size,
136
+ });
137
+ }
138
+
139
+ type SwitchThumbIconsProps = {
140
+ progress: SharedValue<number>;
141
+ checkedIcon: ReactNode;
142
+ uncheckedIcon: ReactNode;
143
+ checkedIconColor: string;
144
+ uncheckedIconColor: string;
145
+ iconSize: number;
146
+ };
147
+
148
+ function SwitchThumbIcons({
149
+ progress,
150
+ checkedIcon,
151
+ uncheckedIcon,
152
+ checkedIconColor,
153
+ uncheckedIconColor,
154
+ iconSize,
155
+ }: SwitchThumbIconsProps) {
156
+ const checkedIconStyle = useAnimatedStyle(() => ({
157
+ opacity: progress.value,
158
+ }));
159
+ const uncheckedIconStyle = useAnimatedStyle(() => ({
160
+ opacity: 1 - progress.value,
161
+ }));
162
+
163
+ return (
164
+ <>
165
+ <Animated.View
166
+ style={[StyleSheet.absoluteFill, CENTER, uncheckedIconStyle]}
167
+ >
168
+ {renderThumbIcon(uncheckedIcon, uncheckedIconColor, iconSize)}
169
+ </Animated.View>
170
+ <Animated.View
171
+ style={[StyleSheet.absoluteFill, CENTER, checkedIconStyle]}
172
+ >
173
+ {renderThumbIcon(checkedIcon, checkedIconColor, iconSize)}
174
+ </Animated.View>
175
+ </>
176
+ );
177
+ }
178
+
179
+ const Switch = memo(function Switch({
64
180
  className,
65
181
  size = "md",
66
- variant,
182
+ variant = "regular",
67
183
  checkedIcon,
68
184
  uncheckedIcon,
69
185
  checked,
70
186
  defaultChecked,
71
187
  onCheckedChange,
72
- disabled,
188
+ disabled = false,
73
189
  style,
74
190
  ...props
75
191
  }: SwitchProps) {
76
192
  const resolvedSize = size ?? "md";
77
193
  const dimensions = SWITCH_DIMENSIONS[resolvedSize];
78
- const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
194
+ const thumbSize = THUMB_SIZES[resolvedSize];
195
+ const iconSize = ICON_SIZES[resolvedSize];
196
+ const [internalChecked, setInternalChecked] = useState(
197
+ defaultChecked ?? false
198
+ );
79
199
  const isControlled = checked !== undefined;
80
- const isChecked = isControlled ? (checked ?? false) : internalChecked;
81
-
82
- const trackOn = useThemeColor("primary-background-fill");
83
- const trackOff = useThemeColor("surface-card");
84
- const trackDisabled = useThemeColor("gray-background-soft-muted");
85
- const trackBorder = useThemeColor("gray-stroke-muted");
86
- const thumbOn = useThemeColor("primary-foreground-onfill");
87
- const thumbOff = useThemeColor("gray-foreground-muted");
88
- const thumbDisabled = useThemeColor("gray-background-fill-muted");
89
-
90
- const translateX = useSharedValue(isChecked ? THUMB_TRANSLATE[resolvedSize] : 2);
91
-
92
- useEffect(() => {
93
- translateX.value = withTiming(isChecked ? THUMB_TRANSLATE[resolvedSize] : 2, {
94
- duration: 180,
95
- easing: Easing.out(Easing.ease),
96
- });
97
- }, [isChecked, resolvedSize, translateX]);
98
-
99
- const thumbStyle = useAnimatedStyle(() => ({
100
- transform: [{ translateX: translateX.value }],
101
- }));
102
-
200
+ const isChecked = isControlled ? checked : internalChecked;
103
201
  const hasIcons = Boolean(checkedIcon || uncheckedIcon);
104
- const thumbSize = THUMB_SIZES[resolvedSize];
202
+
203
+ const checkedIconColor = useThemeColor(
204
+ disabled ? "gray-300" : "primary-foreground-onfill"
205
+ );
206
+ const uncheckedIconColor = useThemeColor(
207
+ disabled ? "gray-300" : "gray-foreground"
208
+ );
209
+ const trackOffColor = useThemeColor(
210
+ disabled ? "gray-background-soft-muted" : "surface-card"
211
+ );
212
+ const trackOnColor = useThemeColor(
213
+ disabled ? "gray-background-soft-muted" : "primary-background-fill"
214
+ );
215
+ const borderOffColor = useThemeColor("gray-stroke-muted");
216
+ const borderOnColor = disabled ? borderOffColor : withAlpha(borderOffColor, 0);
217
+ const thumbDisabledColor = useThemeColor("gray-background-fill-muted");
218
+ const thumbOnToken = useThemeColor("primary-foreground-onfill");
219
+ const thumbOffToken = useThemeColor("gray-foreground-muted");
220
+ const thumbOffColor = hasIcons
221
+ ? "rgba(0, 0, 0, 0)"
222
+ : disabled
223
+ ? thumbDisabledColor
224
+ : thumbOffToken;
225
+ const thumbOnColor = hasIcons
226
+ ? "rgba(0, 0, 0, 0)"
227
+ : disabled
228
+ ? thumbDisabledColor
229
+ : thumbOnToken;
230
+
231
+ const offX = getThumbTranslateX(dimensions.width, thumbSize, false, disabled);
232
+ const onX = getThumbTranslateX(dimensions.width, thumbSize, true, disabled);
233
+ // Root has no border — center against the outer height.
234
+ const thumbTop = (dimensions.height - thumbSize) / 2;
235
+
236
+ // Single UI-thread driver for position, colors and icons. In controlled mode
237
+ // the visual only moves when `checked` changes (same as the web Radix switch).
238
+ const progress = useSharedValue(isChecked ? 1 : 0);
239
+ const targetRef = useRef(isChecked);
240
+
241
+ const animateTo = (next: boolean) => {
242
+ if (targetRef.current === next) return;
243
+ targetRef.current = next;
244
+ progress.value = withTiming(next ? 1 : 0, THUMB_MOTION);
245
+ };
246
+
247
+ useLayoutEffect(() => {
248
+ animateTo(isChecked);
249
+ }, [isChecked]);
105
250
 
106
251
  const handleCheckedChange = (nextChecked: boolean) => {
107
- if (!isControlled) setInternalChecked(nextChecked);
252
+ if (!isControlled) {
253
+ animateTo(nextChecked);
254
+ setInternalChecked(nextChecked);
255
+ }
108
256
  onCheckedChange?.(nextChecked);
109
257
  };
110
258
 
111
- const trackBackgroundColor = disabled
112
- ? trackDisabled
113
- : isChecked
114
- ? trackOn
115
- : trackOff;
116
-
117
- const trackBorderColor = disabled || !isChecked ? trackBorder : "transparent";
118
-
119
- const thumbBackgroundColor = disabled
120
- ? thumbDisabled
121
- : hasIcons
122
- ? "transparent"
123
- : isChecked
124
- ? thumbOn
125
- : thumbOff;
126
-
127
- const rootStyle: ViewStyle = {
128
- width: dimensions.width,
129
- height: dimensions.height,
130
- minWidth: dimensions.width,
131
- minHeight: dimensions.height,
132
- borderRadius: dimensions.height / 2,
133
- borderWidth: 2,
134
- alignItems: "flex-start",
135
- justifyContent: "center",
136
- backgroundColor: trackBackgroundColor,
137
- borderColor: trackBorderColor,
138
- };
259
+ const trackMotionStyle = useAnimatedStyle(() => ({
260
+ backgroundColor: interpolateColor(
261
+ progress.value,
262
+ [0, 1],
263
+ [trackOffColor, trackOnColor]
264
+ ),
265
+ borderColor: interpolateColor(
266
+ progress.value,
267
+ [0, 1],
268
+ [borderOffColor, borderOnColor]
269
+ ),
270
+ }));
271
+
272
+ const thumbMotionStyle = useAnimatedStyle(() => ({
273
+ transform: [{ translateX: offX + (onX - offX) * progress.value }],
274
+ }), [offX, onX]);
275
+
276
+ const thumbColorStyle = useAnimatedStyle(() => ({
277
+ backgroundColor: interpolateColor(
278
+ progress.value,
279
+ [0, 1],
280
+ [thumbOffColor, thumbOnColor]
281
+ ),
282
+ }));
283
+
284
+ const baseRootStyle = useMemo<ViewStyle>(
285
+ () => ({
286
+ width: dimensions.width,
287
+ height: dimensions.height,
288
+ minWidth: dimensions.width,
289
+ minHeight: dimensions.height,
290
+ borderRadius: dimensions.height / 2,
291
+ borderWidth: 0,
292
+ padding: 0,
293
+ overflow: "visible",
294
+ }),
295
+ [dimensions.height, dimensions.width]
296
+ );
297
+
298
+ const trackStyle = useMemo<ViewStyle>(
299
+ () => ({
300
+ ...StyleSheet.absoluteFill,
301
+ borderRadius: dimensions.height / 2,
302
+ borderWidth: BORDER_WIDTH,
303
+ }),
304
+ [dimensions.height]
305
+ );
306
+
307
+ const thumbStyle = useMemo<ViewStyle>(
308
+ () => ({
309
+ position: "absolute",
310
+ top: thumbTop,
311
+ left: 0,
312
+ width: thumbSize,
313
+ height: thumbSize,
314
+ borderRadius: thumbSize / 2,
315
+ alignItems: "center",
316
+ justifyContent: "center",
317
+ }),
318
+ [thumbSize, thumbTop]
319
+ );
320
+
321
+ const hitSlop = useMemo(
322
+ () => getMinTouchHitSlop(dimensions.width, dimensions.height),
323
+ [dimensions.height, dimensions.width]
324
+ );
139
325
 
140
326
  return (
141
- <SwitchPrimitive.Root
327
+ <StyledSwitchRoot
328
+ hitSlop={hitSlop}
329
+ {...props}
142
330
  checked={isChecked}
143
331
  onCheckedChange={handleCheckedChange}
144
332
  disabled={disabled}
145
- hitSlop={getMinTouchHitSlop(dimensions.width, dimensions.height)}
146
- style={(state) => [
147
- rootStyle,
148
- typeof style === "function" ? style(state) : style,
149
- ]}
150
- className={cn(switchVariants({ size, variant }), className)}
151
- {...props}
333
+ className={cn(
334
+ switchVariants({ size: resolvedSize, variant }),
335
+ className
336
+ )}
337
+ // Not a function: with a `className`, NativeWind spreads `style` into an
338
+ // object, and a function spreads to nothing.
339
+ style={[baseRootStyle, style]}
152
340
  >
153
- <SwitchPrimitive.Thumb asChild>
154
- <Animated.View
155
- style={[
156
- thumbStyle,
157
- {
158
- width: thumbSize,
159
- height: thumbSize,
160
- borderRadius: thumbSize / 2,
161
- backgroundColor: thumbBackgroundColor,
162
- },
163
- ]}
164
- >
165
- {hasIcons ? (
166
- <View className="flex-1 items-center justify-center">
167
- {isChecked && checkedIcon}
168
- {!isChecked && uncheckedIcon}
169
- </View>
170
- ) : null}
171
- </Animated.View>
172
- </SwitchPrimitive.Thumb>
173
- </SwitchPrimitive.Root>
341
+ <Animated.View
342
+ pointerEvents="none"
343
+ style={[trackStyle, trackMotionStyle]}
344
+ />
345
+ <Animated.View
346
+ pointerEvents="none"
347
+ collapsable={false}
348
+ style={[thumbStyle, thumbMotionStyle, thumbColorStyle]}
349
+ >
350
+ {hasIcons ? (
351
+ <SwitchThumbIcons
352
+ progress={progress}
353
+ checkedIcon={checkedIcon}
354
+ uncheckedIcon={uncheckedIcon}
355
+ checkedIconColor={checkedIconColor}
356
+ uncheckedIconColor={uncheckedIconColor}
357
+ iconSize={iconSize}
358
+ />
359
+ ) : null}
360
+ </Animated.View>
361
+ </StyledSwitchRoot>
174
362
  );
175
- }
363
+ });
176
364
 
177
365
  export { Switch, switchVariants };
178
366
  export type { SwitchProps };