@retray-dev/ui-kit 6.0.0 → 6.2.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/COMPONENTS.md +8 -7
- package/dist/index.d.mts +47 -23
- package/dist/index.d.ts +47 -23
- package/dist/index.js +702 -634
- package/dist/index.mjs +695 -627
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.tsx +10 -12
- package/src/components/Button/Button.tsx +20 -18
- package/src/components/Card/Card.tsx +21 -33
- package/src/components/CategoryStrip/CategoryStrip.tsx +45 -38
- package/src/components/Checkbox/Checkbox.tsx +31 -50
- package/src/components/Chip/Chip.tsx +34 -71
- package/src/components/DetailRow/DetailRow.tsx +13 -8
- package/src/components/IconButton/IconButton.tsx +20 -18
- package/src/components/Input/Input.tsx +39 -22
- package/src/components/ListItem/ListItem.tsx +22 -34
- package/src/components/MediaCard/MediaCard.tsx +24 -24
- package/src/components/MenuItem/MenuItem.tsx +52 -39
- package/src/components/MonthPicker/MonthPicker.tsx +12 -2
- package/src/components/Pressable/Pressable.tsx +27 -46
- package/src/components/Progress/Progress.tsx +21 -12
- package/src/components/RadioGroup/RadioGroup.tsx +52 -26
- package/src/components/Select/Select.tsx +17 -15
- package/src/components/Sheet/Sheet.tsx +4 -1
- package/src/components/Skeleton/Skeleton.tsx +24 -13
- package/src/components/Slider/Slider.tsx +11 -1
- package/src/components/Switch/Switch.tsx +44 -49
- package/src/components/Tabs/Tabs.tsx +39 -31
- package/src/components/Textarea/Textarea.tsx +29 -12
- package/src/components/Toggle/Toggle.tsx +39 -45
- package/src/utils/animations.ts +58 -0
- package/src/utils/useColorTransition.ts +40 -0
- package/src/utils/usePressScale.ts +73 -0
|
@@ -56,16 +56,23 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
return (
|
|
59
|
-
<View style={[styles.container, style]}>
|
|
59
|
+
<View style={[styles.container, style]} accessibilityRole="adjustable" accessibilityLabel={getLabel()}>
|
|
60
60
|
<TouchableOpacity
|
|
61
61
|
style={styles.arrow}
|
|
62
62
|
onPress={handlePrev}
|
|
63
63
|
activeOpacity={0.6}
|
|
64
64
|
touchSoundDisabled={true}
|
|
65
|
+
accessibilityRole="button"
|
|
66
|
+
accessibilityLabel="Previous month"
|
|
67
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
65
68
|
>
|
|
66
69
|
<Entypo name="chevron-left" size={22} color={colors.foreground} />
|
|
67
70
|
</TouchableOpacity>
|
|
68
|
-
<Text
|
|
71
|
+
<Text
|
|
72
|
+
style={[styles.label, { color: colors.foreground }]}
|
|
73
|
+
allowFontScaling={true}
|
|
74
|
+
accessibilityLiveRegion="polite"
|
|
75
|
+
>
|
|
69
76
|
{getLabel()}
|
|
70
77
|
</Text>
|
|
71
78
|
<TouchableOpacity
|
|
@@ -73,6 +80,9 @@ export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style
|
|
|
73
80
|
onPress={handleNext}
|
|
74
81
|
activeOpacity={0.6}
|
|
75
82
|
touchSoundDisabled={true}
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel="Next month"
|
|
85
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
76
86
|
>
|
|
77
87
|
<Entypo name="chevron-right" size={22} color={colors.foreground} />
|
|
78
88
|
</TouchableOpacity>
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
Animated,
|
|
5
|
-
ViewStyle,
|
|
6
|
-
Platform,
|
|
7
|
-
TouchableOpacityProps,
|
|
8
|
-
} from 'react-native'
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { TouchableOpacity, Platform, ViewStyle, TouchableOpacityProps } from 'react-native'
|
|
3
|
+
import Animated from 'react-native-reanimated'
|
|
9
4
|
import { impactLight } from '../../utils/haptics'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const nativeDriver = Platform.OS !== 'web'
|
|
5
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
6
|
+
import { PRESS_SCALE, SPRINGS } from '../../utils/animations'
|
|
13
7
|
|
|
14
8
|
export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpacity'> {
|
|
15
9
|
/** Children content to render inside the pressable. */
|
|
@@ -18,7 +12,10 @@ export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpaci
|
|
|
18
12
|
onPress?: () => void
|
|
19
13
|
/** Scale value on press. Defaults to `0.98` (MediaCard-style). */
|
|
20
14
|
pressScale?: number
|
|
21
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated Use Reanimated spring config via `pressOutSpring` instead. Ignored.
|
|
17
|
+
* Kept for backwards compatibility with v6.x consumers.
|
|
18
|
+
*/
|
|
22
19
|
bounciness?: number
|
|
23
20
|
/** Enable haptic feedback on press. Defaults to `true`. */
|
|
24
21
|
haptics?: boolean
|
|
@@ -31,42 +28,29 @@ export interface PressableProps extends Omit<TouchableOpacityProps, 'activeOpaci
|
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
/**
|
|
34
|
-
* Generic pressable with
|
|
35
|
-
*
|
|
31
|
+
* Generic pressable with a calibrated spring bounce — Apple HIG / Airbnb feel.
|
|
32
|
+
* All animation runs on the UI thread via Reanimated v4 worklets.
|
|
33
|
+
*
|
|
34
|
+
* Use this for any custom pressable surface that needs consistent press feel
|
|
35
|
+
* (cards, list rows, image tiles, etc).
|
|
36
36
|
*/
|
|
37
37
|
export function Pressable({
|
|
38
38
|
children,
|
|
39
39
|
onPress,
|
|
40
|
-
pressScale =
|
|
41
|
-
bounciness = 4,
|
|
40
|
+
pressScale = PRESS_SCALE.card,
|
|
42
41
|
haptics = true,
|
|
43
42
|
style,
|
|
44
43
|
disabled,
|
|
45
44
|
hoverScale = 1.02,
|
|
46
45
|
...touchableProps
|
|
47
46
|
}: PressableProps) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
useNativeDriver: nativeDriver,
|
|
56
|
-
speed: 40,
|
|
57
|
-
bounciness: 0,
|
|
58
|
-
}).start()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const handlePressOut = () => {
|
|
62
|
-
if (disabled) return
|
|
63
|
-
Animated.spring(scale, {
|
|
64
|
-
toValue: 1,
|
|
65
|
-
useNativeDriver: nativeDriver,
|
|
66
|
-
speed: 40,
|
|
67
|
-
bounciness,
|
|
68
|
-
}).start()
|
|
69
|
-
}
|
|
47
|
+
const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
|
|
48
|
+
pressScale,
|
|
49
|
+
hoverScale,
|
|
50
|
+
pressInSpring: SPRINGS.surfacePressIn,
|
|
51
|
+
pressOutSpring: SPRINGS.surfacePressOut,
|
|
52
|
+
disabled,
|
|
53
|
+
})
|
|
70
54
|
|
|
71
55
|
const handlePress = () => {
|
|
72
56
|
if (disabled || !onPress) return
|
|
@@ -74,23 +58,20 @@ export function Pressable({
|
|
|
74
58
|
onPress()
|
|
75
59
|
}
|
|
76
60
|
|
|
77
|
-
const hoverScaleValue = hovered && hoverScale !== 1 ? hoverScale : 1
|
|
78
|
-
|
|
79
61
|
return (
|
|
80
62
|
<Animated.View
|
|
81
|
-
style={[
|
|
82
|
-
{ transform: [{ scale: Animated.multiply(scale, hoverScaleValue) }] },
|
|
83
|
-
style,
|
|
84
|
-
]}
|
|
63
|
+
style={[animatedStyle, style]}
|
|
85
64
|
{...(Platform.OS === 'web' ? hoverHandlers : {})}
|
|
86
65
|
>
|
|
87
66
|
<TouchableOpacity
|
|
88
67
|
onPress={handlePress}
|
|
89
|
-
onPressIn={
|
|
90
|
-
onPressOut={
|
|
68
|
+
onPressIn={onPressIn}
|
|
69
|
+
onPressOut={onPressOut}
|
|
91
70
|
activeOpacity={1}
|
|
92
71
|
disabled={disabled}
|
|
93
72
|
touchSoundDisabled={true}
|
|
73
|
+
accessibilityRole="button"
|
|
74
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
94
75
|
{...touchableProps}
|
|
95
76
|
>
|
|
96
77
|
{children}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { View,
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { View, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSpring,
|
|
7
|
+
} from 'react-native-reanimated'
|
|
3
8
|
import { useTheme } from '../../theme'
|
|
4
9
|
import { vs } from '../../utils/scaling'
|
|
10
|
+
import { SPRINGS } from '../../utils/animations'
|
|
5
11
|
|
|
6
12
|
export type ProgressVariant = 'default' | 'success' | 'warning' | 'destructive'
|
|
7
13
|
|
|
@@ -10,23 +16,23 @@ export interface ProgressProps {
|
|
|
10
16
|
max?: number
|
|
11
17
|
variant?: ProgressVariant
|
|
12
18
|
style?: ViewStyle
|
|
19
|
+
accessibilityLabel?: string
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
export function Progress({ value = 0, max = 100, variant = 'default', style }: ProgressProps) {
|
|
22
|
+
export function Progress({ value = 0, max = 100, variant = 'default', style, accessibilityLabel }: ProgressProps) {
|
|
16
23
|
const { colors } = useTheme()
|
|
17
24
|
const percent = Math.min(Math.max((value / max) * 100, 0), 100)
|
|
18
25
|
const [trackWidth, setTrackWidth] = useState(0)
|
|
19
|
-
const animatedWidth =
|
|
26
|
+
const animatedWidth = useSharedValue(0)
|
|
20
27
|
|
|
21
28
|
useEffect(() => {
|
|
22
29
|
if (trackWidth === 0) return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}, [percent, trackWidth])
|
|
30
|
+
animatedWidth.value = withSpring((percent / 100) * trackWidth, SPRINGS.glide)
|
|
31
|
+
}, [percent, trackWidth, animatedWidth])
|
|
32
|
+
|
|
33
|
+
const indicatorAnimatedStyle = useAnimatedStyle(() => ({
|
|
34
|
+
width: animatedWidth.value,
|
|
35
|
+
}))
|
|
30
36
|
|
|
31
37
|
const indicatorColor =
|
|
32
38
|
variant === 'success' ? colors.success
|
|
@@ -38,9 +44,12 @@ export function Progress({ value = 0, max = 100, variant = 'default', style }: P
|
|
|
38
44
|
<View
|
|
39
45
|
style={[styles.track, { backgroundColor: colors.surface }, style]}
|
|
40
46
|
onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
|
|
47
|
+
accessibilityRole="progressbar"
|
|
48
|
+
accessibilityLabel={accessibilityLabel}
|
|
49
|
+
accessibilityValue={{ min: 0, max: 100, now: Math.round(percent) }}
|
|
41
50
|
>
|
|
42
51
|
<Animated.View
|
|
43
|
-
style={[styles.indicator, {
|
|
52
|
+
style={[styles.indicator, { backgroundColor: indicatorColor }, indicatorAnimatedStyle]}
|
|
44
53
|
/>
|
|
45
54
|
</View>
|
|
46
55
|
)
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import { TouchableOpacity,
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { TouchableOpacity, View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withSpring,
|
|
7
|
+
interpolateColor,
|
|
8
|
+
} from 'react-native-reanimated'
|
|
9
|
+
import { useEffect } from 'react'
|
|
3
10
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
4
|
-
|
|
5
|
-
const nativeDriver = Platform.OS !== 'web'
|
|
6
11
|
import { useTheme } from '../../theme'
|
|
7
12
|
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
13
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
14
|
+
import { useColorTransition } from '../../utils/useColorTransition'
|
|
15
|
+
import { SPRINGS, PRESS_SCALE } from '../../utils/animations'
|
|
8
16
|
|
|
9
17
|
export interface RadioOption {
|
|
10
18
|
label: string
|
|
@@ -18,6 +26,7 @@ export interface RadioGroupProps {
|
|
|
18
26
|
onValueChange?: (value: string) => void
|
|
19
27
|
orientation?: 'vertical' | 'horizontal'
|
|
20
28
|
style?: ViewStyle
|
|
29
|
+
accessibilityLabel?: string
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
function RadioItem({
|
|
@@ -30,16 +39,26 @@ function RadioItem({
|
|
|
30
39
|
onSelect: () => void
|
|
31
40
|
}) {
|
|
32
41
|
const { colors } = useTheme()
|
|
33
|
-
const
|
|
42
|
+
const { animatedStyle: scaleStyle, onPressIn, onPressOut } = usePressScale({
|
|
43
|
+
pressScale: PRESS_SCALE.button,
|
|
44
|
+
disabled: option.disabled,
|
|
45
|
+
})
|
|
46
|
+
const colorProgress = useColorTransition(selected)
|
|
47
|
+
|
|
48
|
+
// Pop-in animation for the inner dot
|
|
49
|
+
const dotScale = useSharedValue(selected ? 1 : 0)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
dotScale.value = withSpring(selected ? 1 : 0, SPRINGS.elastic)
|
|
52
|
+
}, [selected, dotScale])
|
|
34
53
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
54
|
+
const radioStyle = useAnimatedStyle(() => ({
|
|
55
|
+
borderColor: interpolateColor(colorProgress.value, [0, 1], [colors.border, colors.primary]),
|
|
56
|
+
}))
|
|
39
57
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
const dotStyle = useAnimatedStyle(() => ({
|
|
59
|
+
transform: [{ scale: dotScale.value }],
|
|
60
|
+
opacity: dotScale.value,
|
|
61
|
+
}))
|
|
43
62
|
|
|
44
63
|
return (
|
|
45
64
|
<TouchableOpacity
|
|
@@ -50,23 +69,25 @@ function RadioItem({
|
|
|
50
69
|
onSelect()
|
|
51
70
|
}
|
|
52
71
|
}}
|
|
53
|
-
onPressIn={
|
|
54
|
-
onPressOut={
|
|
72
|
+
onPressIn={onPressIn}
|
|
73
|
+
onPressOut={onPressOut}
|
|
55
74
|
activeOpacity={1}
|
|
56
75
|
touchSoundDisabled={true}
|
|
57
76
|
disabled={option.disabled}
|
|
77
|
+
accessibilityRole="radio"
|
|
78
|
+
accessibilityLabel={option.label}
|
|
79
|
+
accessibilityState={{ checked: selected, disabled: !!option.disabled }}
|
|
58
80
|
>
|
|
59
|
-
<Animated.View
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
{selected ? <View style={[styles.dot, { backgroundColor: colors.primary }]} /> : null}
|
|
81
|
+
<Animated.View style={scaleStyle}>
|
|
82
|
+
<Animated.View
|
|
83
|
+
style={[
|
|
84
|
+
styles.radio,
|
|
85
|
+
{ opacity: option.disabled ? 0.45 : 1 },
|
|
86
|
+
radioStyle,
|
|
87
|
+
]}
|
|
88
|
+
>
|
|
89
|
+
<Animated.View style={[styles.dot, { backgroundColor: colors.primary }, dotStyle]} />
|
|
90
|
+
</Animated.View>
|
|
70
91
|
</Animated.View>
|
|
71
92
|
<Text
|
|
72
93
|
style={[
|
|
@@ -87,9 +108,14 @@ export function RadioGroup({
|
|
|
87
108
|
onValueChange,
|
|
88
109
|
orientation = 'vertical',
|
|
89
110
|
style,
|
|
111
|
+
accessibilityLabel,
|
|
90
112
|
}: RadioGroupProps) {
|
|
91
113
|
return (
|
|
92
|
-
<View
|
|
114
|
+
<View
|
|
115
|
+
style={[styles.container, orientation === 'horizontal' && styles.horizontal, style]}
|
|
116
|
+
accessibilityRole="radiogroup"
|
|
117
|
+
accessibilityLabel={accessibilityLabel}
|
|
118
|
+
>
|
|
93
119
|
{options.map((option) => (
|
|
94
120
|
<RadioItem
|
|
95
121
|
key={option.value}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import React, { useRef, useState } from 'react'
|
|
2
|
-
import { View, Text, TouchableOpacity, Modal,
|
|
2
|
+
import { View, Text, TouchableOpacity, Modal, StyleSheet, ViewStyle, Platform } from 'react-native'
|
|
3
|
+
import Animated from 'react-native-reanimated'
|
|
3
4
|
import { Picker } from '@react-native-picker/picker'
|
|
4
5
|
import { Entypo } from '@expo/vector-icons'
|
|
5
6
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
6
7
|
import { useTheme } from '../../theme'
|
|
7
8
|
import { s, vs, ms } from '../../utils/scaling'
|
|
9
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
10
|
+
import { PRESS_SCALE } from '../../utils/animations'
|
|
8
11
|
|
|
9
12
|
const isIOS = Platform.OS === 'ios'
|
|
10
13
|
const isAndroid = Platform.OS === 'android'
|
|
11
14
|
const isWeb = Platform.OS === 'web'
|
|
12
|
-
const nativeDriver = Platform.OS !== 'web'
|
|
13
15
|
|
|
14
16
|
export interface SelectOption {
|
|
15
17
|
label: string
|
|
@@ -26,6 +28,7 @@ export interface SelectProps {
|
|
|
26
28
|
error?: string
|
|
27
29
|
disabled?: boolean
|
|
28
30
|
style?: ViewStyle
|
|
31
|
+
accessibilityLabel?: string
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export function Select({
|
|
@@ -37,24 +40,19 @@ export function Select({
|
|
|
37
40
|
error,
|
|
38
41
|
disabled,
|
|
39
42
|
style,
|
|
43
|
+
accessibilityLabel,
|
|
40
44
|
}: SelectProps) {
|
|
41
45
|
const { colors } = useTheme()
|
|
42
|
-
const
|
|
46
|
+
const { animatedStyle, onPressIn, onPressOut } = usePressScale({
|
|
47
|
+
pressScale: PRESS_SCALE.button,
|
|
48
|
+
disabled,
|
|
49
|
+
})
|
|
43
50
|
const [pickerVisible, setPickerVisible] = useState(false)
|
|
44
51
|
const [pendingValue, setPendingValue] = useState<string | undefined>(value)
|
|
45
52
|
const pickerRef = useRef<React.ElementRef<typeof Picker>>(null)
|
|
46
53
|
|
|
47
54
|
const selected = options.find((o) => o.value === value)
|
|
48
55
|
|
|
49
|
-
const handlePressIn = () => {
|
|
50
|
-
if (disabled) return
|
|
51
|
-
Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const handlePressOut = () => {
|
|
55
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
56
|
const handleOpen = () => {
|
|
59
57
|
if (disabled) return
|
|
60
58
|
hapticSelection()
|
|
@@ -84,7 +82,7 @@ export function Select({
|
|
|
84
82
|
|
|
85
83
|
{/* Trigger button — shown on iOS and Android only */}
|
|
86
84
|
{!isWeb ? (
|
|
87
|
-
<Animated.View style={
|
|
85
|
+
<Animated.View style={[animatedStyle, { opacity: disabled ? 0.45 : 1 }]}>
|
|
88
86
|
<TouchableOpacity
|
|
89
87
|
style={[
|
|
90
88
|
styles.trigger,
|
|
@@ -94,10 +92,14 @@ export function Select({
|
|
|
94
92
|
},
|
|
95
93
|
]}
|
|
96
94
|
onPress={handleOpen}
|
|
97
|
-
onPressIn={
|
|
98
|
-
onPressOut={
|
|
95
|
+
onPressIn={onPressIn}
|
|
96
|
+
onPressOut={onPressOut}
|
|
99
97
|
activeOpacity={1}
|
|
100
98
|
touchSoundDisabled={true}
|
|
99
|
+
accessibilityRole="combobox"
|
|
100
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
101
|
+
accessibilityValue={{ text: selected?.label ?? placeholder }}
|
|
102
|
+
accessibilityState={{ disabled: !!disabled, expanded: pickerVisible }}
|
|
101
103
|
>
|
|
102
104
|
<Text
|
|
103
105
|
style={[
|
|
@@ -139,7 +139,7 @@ export function Sheet({
|
|
|
139
139
|
const showHeader = !!(title || effectiveSubtitle || showCloseButton)
|
|
140
140
|
|
|
141
141
|
const headerNode = showHeader ? (
|
|
142
|
-
<View style={styles.header}>
|
|
142
|
+
<View style={styles.header} accessibilityRole="header">
|
|
143
143
|
<View style={styles.headerRow}>
|
|
144
144
|
{title ? (
|
|
145
145
|
<Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>
|
|
@@ -152,6 +152,9 @@ export function Sheet({
|
|
|
152
152
|
style={styles.closeButton}
|
|
153
153
|
activeOpacity={0.6}
|
|
154
154
|
touchSoundDisabled={true}
|
|
155
|
+
accessibilityRole="button"
|
|
156
|
+
accessibilityLabel="Close"
|
|
157
|
+
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
155
158
|
>
|
|
156
159
|
<AntDesign name="close" size={ms(18)} color={colors.foregroundMuted} />
|
|
157
160
|
</TouchableOpacity>
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import React, { useEffect,
|
|
2
|
-
import {
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { StyleSheet, View, ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
Easing,
|
|
9
|
+
} from 'react-native-reanimated'
|
|
3
10
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
4
11
|
import { useTheme } from '../../theme'
|
|
5
12
|
import { s } from '../../utils/scaling'
|
|
13
|
+
import { TIMINGS } from '../../utils/animations'
|
|
6
14
|
|
|
7
15
|
// circle: circular avatar placeholder text: short line preset base: custom dimensions
|
|
8
16
|
export type SkeletonPreset = 'base' | 'circle' | 'text'
|
|
@@ -27,24 +35,24 @@ export function Skeleton({
|
|
|
27
35
|
style,
|
|
28
36
|
}: SkeletonProps) {
|
|
29
37
|
const { colors, colorScheme } = useTheme()
|
|
30
|
-
const
|
|
38
|
+
const shimmer = useSharedValue(0)
|
|
31
39
|
const [containerWidth, setContainerWidth] = useState(300)
|
|
32
40
|
|
|
33
41
|
const shimmerHighlight =
|
|
34
42
|
colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'
|
|
35
43
|
|
|
36
44
|
useEffect(() => {
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
// Repeats indefinitely on the UI thread — zero JS bridge cost per frame.
|
|
46
|
+
shimmer.value = withRepeat(
|
|
47
|
+
withTiming(1, { duration: TIMINGS.shimmer.duration, easing: Easing.linear }),
|
|
48
|
+
-1,
|
|
49
|
+
false,
|
|
39
50
|
)
|
|
40
|
-
|
|
41
|
-
return () => animation.stop()
|
|
42
|
-
}, [shimmerAnim])
|
|
51
|
+
}, [shimmer])
|
|
43
52
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
})
|
|
53
|
+
const shimmerStyle = useAnimatedStyle(() => ({
|
|
54
|
+
transform: [{ translateX: -containerWidth + shimmer.value * (containerWidth * 2) }],
|
|
55
|
+
}))
|
|
48
56
|
|
|
49
57
|
// Resolve dimensions by preset
|
|
50
58
|
const resolvedWidth: number | string =
|
|
@@ -70,8 +78,11 @@ export function Skeleton({
|
|
|
70
78
|
style,
|
|
71
79
|
]}
|
|
72
80
|
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
|
|
81
|
+
accessibilityRole="progressbar"
|
|
82
|
+
accessibilityLabel="Loading"
|
|
83
|
+
accessibilityState={{ busy: true }}
|
|
73
84
|
>
|
|
74
|
-
<Animated.View style={[StyleSheet.absoluteFill,
|
|
85
|
+
<Animated.View style={[StyleSheet.absoluteFill, shimmerStyle]}>
|
|
75
86
|
<LinearGradient
|
|
76
87
|
colors={['transparent', shimmerHighlight, 'transparent']}
|
|
77
88
|
start={{ x: 0, y: 0 }}
|
|
@@ -46,7 +46,17 @@ export function Slider({
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
return (
|
|
49
|
-
<View
|
|
49
|
+
<View
|
|
50
|
+
style={[styles.wrapper, style]}
|
|
51
|
+
accessibilityRole="adjustable"
|
|
52
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
53
|
+
accessibilityValue={{
|
|
54
|
+
min: minimumValue,
|
|
55
|
+
max: maximumValue,
|
|
56
|
+
now: value,
|
|
57
|
+
text: formatValue(value),
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
50
60
|
{label || showValue ? (
|
|
51
61
|
<View style={styles.header}>
|
|
52
62
|
{label ? (
|
|
@@ -1,63 +1,62 @@
|
|
|
1
|
-
import React, { useEffect
|
|
2
|
-
import { TouchableOpacity,
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { TouchableOpacity, StyleSheet, ViewStyle, View } from 'react-native'
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSpring,
|
|
7
|
+
withTiming,
|
|
8
|
+
interpolateColor,
|
|
9
|
+
} from 'react-native-reanimated'
|
|
3
10
|
import { Feather } from '@expo/vector-icons'
|
|
4
|
-
|
|
5
|
-
const nativeDriver = Platform.OS !== 'web'
|
|
6
11
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
7
12
|
import { useTheme } from '../../theme'
|
|
8
13
|
import { s } from '../../utils/scaling'
|
|
14
|
+
import { SPRINGS, TIMINGS, EASINGS } from '../../utils/animations'
|
|
9
15
|
|
|
10
16
|
const TRACK_WIDTH = s(52)
|
|
11
17
|
const TRACK_HEIGHT = s(30)
|
|
12
18
|
const THUMB_SIZE = s(24)
|
|
13
19
|
const THUMB_OFFSET = s(3)
|
|
14
20
|
const THUMB_TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2
|
|
21
|
+
const ICON_SIZE = s(13)
|
|
15
22
|
|
|
16
23
|
export interface SwitchProps {
|
|
17
24
|
checked?: boolean
|
|
18
25
|
onCheckedChange?: (checked: boolean) => void
|
|
19
26
|
disabled?: boolean
|
|
20
27
|
style?: ViewStyle
|
|
28
|
+
accessibilityLabel?: string
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export function Switch({ checked = false, onCheckedChange, disabled, style }: SwitchProps) {
|
|
31
|
+
export function Switch({ checked = false, onCheckedChange, disabled, style, accessibilityLabel }: SwitchProps) {
|
|
26
32
|
const { colors } = useTheme()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const crossOpacity = useRef(new Animated.Value(checked ? 0 : 1)).current
|
|
33
|
+
|
|
34
|
+
// Single 0→1 progress drives thumb position, track color, and icon crossfade — all UI thread.
|
|
35
|
+
const progress = useSharedValue(checked ? 1 : 0)
|
|
31
36
|
|
|
32
37
|
useEffect(() => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
duration: 120,
|
|
52
|
-
useNativeDriver: true,
|
|
53
|
-
}),
|
|
54
|
-
]).start()
|
|
55
|
-
}, [checked, translateX, trackOpacity, checkOpacity, crossOpacity])
|
|
38
|
+
progress.value = withSpring(checked ? 1 : 0, SPRINGS.elastic)
|
|
39
|
+
}, [checked, progress])
|
|
40
|
+
|
|
41
|
+
const thumbStyle = useAnimatedStyle(() => ({
|
|
42
|
+
transform: [{ translateX: progress.value * THUMB_TRAVEL }],
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const trackStyle = useAnimatedStyle(() => ({
|
|
46
|
+
backgroundColor: interpolateColor(
|
|
47
|
+
progress.value,
|
|
48
|
+
[0, 1],
|
|
49
|
+
[colors.surfaceStrong, colors.primary],
|
|
50
|
+
),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const checkIconStyle = useAnimatedStyle(() => ({
|
|
54
|
+
opacity: withTiming(checked ? 1 : 0, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
|
|
55
|
+
}))
|
|
56
56
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
57
|
+
const crossIconStyle = useAnimatedStyle(() => ({
|
|
58
|
+
opacity: withTiming(checked ? 0 : 1, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
|
|
59
|
+
}))
|
|
61
60
|
|
|
62
61
|
return (
|
|
63
62
|
<View style={[{ opacity: disabled ? 0.45 : 1 }, style]}>
|
|
@@ -69,19 +68,18 @@ export function Switch({ checked = false, onCheckedChange, disabled, style }: Sw
|
|
|
69
68
|
disabled={disabled}
|
|
70
69
|
activeOpacity={0.8}
|
|
71
70
|
touchSoundDisabled={true}
|
|
72
|
-
|
|
71
|
+
accessibilityRole="switch"
|
|
72
|
+
accessibilityLabel={accessibilityLabel}
|
|
73
|
+
accessibilityState={{ checked, disabled: !!disabled }}
|
|
73
74
|
>
|
|
74
|
-
<Animated.View style={[styles.track,
|
|
75
|
+
<Animated.View style={[styles.track, trackStyle]}>
|
|
75
76
|
<Animated.View
|
|
76
|
-
style={[
|
|
77
|
-
styles.thumb,
|
|
78
|
-
{ backgroundColor: colors.primaryForeground, transform: [{ translateX }] },
|
|
79
|
-
]}
|
|
77
|
+
style={[styles.thumb, { backgroundColor: colors.primaryForeground }, thumbStyle]}
|
|
80
78
|
>
|
|
81
|
-
<Animated.View style={[styles.iconWrapper,
|
|
79
|
+
<Animated.View style={[styles.iconWrapper, checkIconStyle]}>
|
|
82
80
|
<Feather name="check" size={ICON_SIZE} color={colors.primary} />
|
|
83
81
|
</Animated.View>
|
|
84
|
-
<Animated.View style={[styles.iconWrapper,
|
|
82
|
+
<Animated.View style={[styles.iconWrapper, crossIconStyle]}>
|
|
85
83
|
<Feather name="x" size={ICON_SIZE} color={colors.foregroundMuted} />
|
|
86
84
|
</Animated.View>
|
|
87
85
|
</Animated.View>
|
|
@@ -92,13 +90,10 @@ export function Switch({ checked = false, onCheckedChange, disabled, style }: Sw
|
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
const styles = StyleSheet.create({
|
|
95
|
-
wrapper: {},
|
|
96
93
|
track: {
|
|
97
94
|
width: TRACK_WIDTH,
|
|
98
95
|
height: TRACK_HEIGHT,
|
|
99
96
|
borderRadius: TRACK_HEIGHT / 2,
|
|
100
|
-
// No justifyContent/alignItems — thumb uses absolute positioning
|
|
101
|
-
// so the track's flex layout doesn't interfere with translateX animation
|
|
102
97
|
},
|
|
103
98
|
thumb: {
|
|
104
99
|
position: 'absolute',
|