@retray-dev/ui-kit 2.6.0 → 2.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retray-dev/ui-kit",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Personal UI Kit for React Native / Expo",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -148,7 +148,7 @@ export function Button({
148
148
  ) : (
149
149
  <>
150
150
  {effectiveIcon && iconPosition === 'left' && <>{effectiveIcon}</>}
151
- <Text style={[styles.label, labelVariantStyle, labelSizeStyles[size], effectiveIcon ? styles.labelWithIcon : undefined]}>{label}</Text>
151
+ <Text style={[styles.label, labelVariantStyle, labelSizeStyles[size], effectiveIcon ? styles.labelWithIcon : undefined]} allowFontScaling={true}>{label}</Text>
152
152
  {effectiveIcon && iconPosition === 'right' && <>{effectiveIcon}</>}
153
153
  </>
154
154
  )}
@@ -64,6 +64,7 @@ export function Checkbox({
64
64
  {label ? (
65
65
  <Text
66
66
  style={[styles.label, { color: disabled ? colors.mutedForeground : colors.foreground }]}
67
+ allowFontScaling={true}
67
68
  >
68
69
  {label}
69
70
  </Text>
@@ -0,0 +1,147 @@
1
+ import React, { useRef } from 'react'
2
+ import {
3
+ TouchableOpacity,
4
+ Animated,
5
+ ActivityIndicator,
6
+ StyleSheet,
7
+ TouchableOpacityProps,
8
+ ViewStyle,
9
+ Platform,
10
+ } from 'react-native'
11
+
12
+ const nativeDriver = Platform.OS !== 'web'
13
+ import { impactLight } from '../../utils/haptics'
14
+ import { useTheme } from '../../theme'
15
+ import { s } from '../../utils/scaling'
16
+ import { renderIcon } from '../../utils/icons'
17
+
18
+ export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
19
+ export type IconButtonSize = 'sm' | 'md' | 'lg'
20
+
21
+ export interface IconButtonProps extends TouchableOpacityProps {
22
+ /**
23
+ * Icon name from `@expo/vector-icons` (e.g. `"home"`, `"star"`, `"plus"`).
24
+ * See https://icons.expo.fyi. Takes precedence over `icon` when both supplied.
25
+ */
26
+ iconName?: string
27
+ /** ReactNode icon — used when `iconName` is not provided. */
28
+ icon?: React.ReactNode
29
+ /** Override the resolved icon color. Defaults to the foreground color for the active variant. */
30
+ iconColor?: string
31
+ variant?: IconButtonVariant
32
+ size?: IconButtonSize
33
+ /** Replaces icon with a spinner and forces `disabled`. */
34
+ loading?: boolean
35
+ }
36
+
37
+ const sizeMap: Record<IconButtonSize, { container: number; icon: number }> = {
38
+ sm: { container: s(40), icon: 18 },
39
+ md: { container: s(44), icon: 20 },
40
+ lg: { container: s(52), icon: 24 },
41
+ }
42
+
43
+ export function IconButton({
44
+ iconName,
45
+ icon,
46
+ iconColor,
47
+ variant = 'primary',
48
+ size = 'md',
49
+ loading = false,
50
+ disabled,
51
+ style,
52
+ onPress,
53
+ ...props
54
+ }: IconButtonProps) {
55
+ const { colors } = useTheme()
56
+ const isDisabled = disabled || loading
57
+ const scale = useRef(new Animated.Value(1)).current
58
+
59
+ const handlePressIn = () => {
60
+ if (isDisabled) return
61
+ Animated.spring(scale, {
62
+ toValue: 0.95,
63
+ useNativeDriver: nativeDriver,
64
+ speed: 40,
65
+ bounciness: 0,
66
+ }).start()
67
+ }
68
+
69
+ const handlePressOut = () => {
70
+ Animated.spring(scale, {
71
+ toValue: 1,
72
+ useNativeDriver: nativeDriver,
73
+ speed: 40,
74
+ bounciness: 4,
75
+ }).start()
76
+ }
77
+
78
+ const handlePress: TouchableOpacityProps['onPress'] = (e) => {
79
+ impactLight()
80
+ onPress?.(e)
81
+ }
82
+
83
+ const containerVariantStyle: ViewStyle = {
84
+ primary: { backgroundColor: colors.primary },
85
+ secondary: { backgroundColor: colors.secondary },
86
+ outline: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: colors.border },
87
+ ghost: { backgroundColor: 'transparent' },
88
+ destructive: { backgroundColor: colors.destructive },
89
+ }[variant]
90
+
91
+ const defaultIconColor: string = {
92
+ primary: colors.primaryForeground,
93
+ secondary: colors.secondaryForeground,
94
+ outline: colors.foreground,
95
+ ghost: colors.foreground,
96
+ destructive: colors.destructiveForeground,
97
+ }[variant]
98
+
99
+ const spinnerColor =
100
+ variant === 'destructive' ? colors.destructiveForeground
101
+ : variant === 'primary' || variant === 'secondary' ? colors.primaryForeground
102
+ : colors.foreground
103
+
104
+ const { container: containerSize, icon: iconSize } = sizeMap[size]
105
+
106
+ const resolvedIcon: React.ReactNode = iconName
107
+ ? renderIcon(iconName, iconSize, iconColor ?? defaultIconColor)
108
+ : icon
109
+
110
+ return (
111
+ <Animated.View style={{ transform: [{ scale }] }}>
112
+ <TouchableOpacity
113
+ style={[
114
+ styles.base,
115
+ containerVariantStyle,
116
+ { width: containerSize, height: containerSize },
117
+ isDisabled && styles.disabled,
118
+ style,
119
+ ]}
120
+ disabled={isDisabled}
121
+ activeOpacity={1}
122
+ touchSoundDisabled={true}
123
+ onPress={handlePress}
124
+ onPressIn={handlePressIn}
125
+ onPressOut={handlePressOut}
126
+ {...props}
127
+ >
128
+ {loading ? (
129
+ <ActivityIndicator size="small" color={spinnerColor} />
130
+ ) : (
131
+ resolvedIcon
132
+ )}
133
+ </TouchableOpacity>
134
+ </Animated.View>
135
+ )
136
+ }
137
+
138
+ const styles = StyleSheet.create({
139
+ base: {
140
+ borderRadius: 999,
141
+ alignItems: 'center',
142
+ justifyContent: 'center',
143
+ },
144
+ disabled: {
145
+ opacity: 0.5,
146
+ },
147
+ })
@@ -0,0 +1,2 @@
1
+ export { IconButton } from './IconButton'
2
+ export type { IconButtonProps, IconButtonVariant, IconButtonSize } from './IconButton'
@@ -73,6 +73,7 @@ function RadioItem({
73
73
  styles.label,
74
74
  { color: option.disabled ? colors.mutedForeground : colors.foreground },
75
75
  ]}
76
+ allowFontScaling={true}
76
77
  >
77
78
  {option.label}
78
79
  </Text>
@@ -79,7 +79,7 @@ export function Select({
79
79
 
80
80
  return (
81
81
  <View style={[styles.container, style]}>
82
- {label ? <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text> : null}
82
+ {label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
83
83
 
84
84
  {/* Trigger button — shown on iOS and Android only */}
85
85
  {!isWeb ? (
@@ -220,7 +220,7 @@ export function Select({
220
220
  ) : null}
221
221
 
222
222
  {error ? (
223
- <Text style={[styles.helperText, { color: colors.destructive }]}>{error}</Text>
223
+ <Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
224
224
  ) : null}
225
225
  </View>
226
226
  )
@@ -72,10 +72,10 @@ export function Sheet({
72
72
  {title || description ? (
73
73
  <View style={styles.header}>
74
74
  {title ? (
75
- <Text style={[styles.title, { color: colors.cardForeground }]}>{title}</Text>
75
+ <Text style={[styles.title, { color: colors.cardForeground }]} allowFontScaling={true}>{title}</Text>
76
76
  ) : null}
77
77
  {description ? (
78
- <Text style={[styles.description, { color: colors.mutedForeground }]}>
78
+ <Text style={[styles.description, { color: colors.mutedForeground }]} allowFontScaling={true}>
79
79
  {description}
80
80
  </Text>
81
81
  ) : null}
@@ -76,6 +76,7 @@ function TabTrigger({
76
76
  { color: isActive ? colors.foreground : colors.mutedForeground },
77
77
  isActive && styles.activeTriggerLabel,
78
78
  ]}
79
+ allowFontScaling={true}
79
80
  >
80
81
  {tab.label}
81
82
  </Text>
@@ -127,10 +127,10 @@ function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: ()
127
127
  <View style={styles.leftIconContainer}>{leftIcon}</View>
128
128
  <View style={styles.toastContent}>
129
129
  {item.title ? (
130
- <Text style={[styles.toastTitle, { color: textColor }]}>{item.title}</Text>
130
+ <Text style={[styles.toastTitle, { color: textColor }]} allowFontScaling={true}>{item.title}</Text>
131
131
  ) : null}
132
132
  {item.description ? (
133
- <Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]}>
133
+ <Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]} allowFontScaling={true}>
134
134
  {item.description}
135
135
  </Text>
136
136
  ) : null}
@@ -146,7 +146,7 @@ export function Toggle({
146
146
  >
147
147
  <View style={styles.inner}>
148
148
  <LeftIcon />
149
- {label ? <Animated.Text style={[styles.label, { color: textColor }]}>{label}</Animated.Text> : null}
149
+ {label ? <Animated.Text style={[styles.label, { color: textColor }]} allowFontScaling={true}>{label}</Animated.Text> : null}
150
150
  </View>
151
151
  </Animated.View>
152
152
  </TouchableOpacity>
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { defaultLight, defaultDark } from './theme'
5
5
 
6
6
  // Components
7
7
  export * from './components/Button'
8
+ export * from './components/IconButton'
8
9
  export * from './components/Text'
9
10
  export * from './components/Input'
10
11
  export * from './components/Badge'
@@ -40,3 +41,20 @@ export * from './components/MonthPicker'
40
41
  // Icon utility
41
42
  export { Icon, renderIcon } from './utils/icons'
42
43
  export type { IconProps, IconFamily } from './utils/icons'
44
+
45
+ // Design tokens
46
+ export {
47
+ SPACING,
48
+ ICON_SIZES,
49
+ RADIUS,
50
+ SHADOWS,
51
+ BREAKPOINTS,
52
+ } from './tokens'
53
+ export type {
54
+ Spacing,
55
+ SpacingKey,
56
+ IconSize,
57
+ IconSizeKey,
58
+ Radius,
59
+ RadiusKey,
60
+ } from './tokens'
package/src/tokens.ts ADDED
@@ -0,0 +1,69 @@
1
+ export const SPACING = {
2
+ xs: 4,
3
+ sm: 8,
4
+ md: 12,
5
+ lg: 16,
6
+ xl: 24,
7
+ '2xl': 32,
8
+ '3xl': 48,
9
+ } as const
10
+
11
+ export const ICON_SIZES = {
12
+ sm: 14,
13
+ md: 18,
14
+ lg: 22,
15
+ xl: 28,
16
+ '2xl': 32,
17
+ } as const
18
+
19
+ export const RADIUS = {
20
+ sm: 4,
21
+ md: 8,
22
+ lg: 12,
23
+ xl: 16,
24
+ full: 9999,
25
+ } as const
26
+
27
+ export const SHADOWS = {
28
+ sm: {
29
+ shadowColor: '#000',
30
+ shadowOffset: { width: 0, height: 1 },
31
+ shadowOpacity: 0.08,
32
+ shadowRadius: 4,
33
+ elevation: 2,
34
+ },
35
+ md: {
36
+ shadowColor: '#000',
37
+ shadowOffset: { width: 0, height: 3 },
38
+ shadowOpacity: 0.12,
39
+ shadowRadius: 8,
40
+ elevation: 5,
41
+ },
42
+ lg: {
43
+ shadowColor: '#000',
44
+ shadowOffset: { width: 0, height: 6 },
45
+ shadowOpacity: 0.2,
46
+ shadowRadius: 16,
47
+ elevation: 10,
48
+ },
49
+ xl: {
50
+ shadowColor: '#000',
51
+ shadowOffset: { width: 0, height: 12 },
52
+ shadowOpacity: 0.28,
53
+ shadowRadius: 24,
54
+ elevation: 18,
55
+ },
56
+ } as const
57
+
58
+ export const BREAKPOINTS = {
59
+ wide: 700,
60
+ } as const
61
+
62
+ export type Spacing = typeof SPACING
63
+ export type SpacingKey = keyof Spacing
64
+
65
+ export type IconSize = typeof ICON_SIZES
66
+ export type IconSizeKey = keyof IconSize
67
+
68
+ export type Radius = typeof RADIUS
69
+ export type RadiusKey = keyof Radius
@@ -1,32 +1,38 @@
1
1
  import { Platform } from 'react-native'
2
2
 
3
- /**
4
- * Web-safe haptics helpers. All calls are no-ops on web since expo-haptics
5
- * is a native-only module and throws on web.
6
- */
3
+ type HapticsModule = typeof import('expo-haptics')
7
4
 
8
- let Haptics: typeof import('expo-haptics') | null = null
5
+ let _haptics: HapticsModule | null = null
9
6
 
10
- if (Platform.OS !== 'web') {
11
- Haptics = require('expo-haptics')
7
+ async function getHaptics(): Promise<HapticsModule | null> {
8
+ if (Platform.OS === 'web') return null
9
+ if (!_haptics) {
10
+ _haptics = await import('expo-haptics')
11
+ }
12
+ return _haptics
12
13
  }
13
14
 
14
15
  export function selectionAsync(): void {
15
- Haptics?.selectionAsync()
16
+ if (Platform.OS === 'web') return
17
+ getHaptics().then(h => h?.selectionAsync())
16
18
  }
17
19
 
18
20
  export function impactLight(): void {
19
- Haptics?.impactAsync(Haptics.ImpactFeedbackStyle.Light)
21
+ if (Platform.OS === 'web') return
22
+ getHaptics().then(h => h?.impactAsync(h.ImpactFeedbackStyle.Light))
20
23
  }
21
24
 
22
25
  export function impactMedium(): void {
23
- Haptics?.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
26
+ if (Platform.OS === 'web') return
27
+ getHaptics().then(h => h?.impactAsync(h.ImpactFeedbackStyle.Medium))
24
28
  }
25
29
 
26
30
  export function notificationSuccess(): void {
27
- Haptics?.notificationAsync(Haptics.NotificationFeedbackType.Success)
31
+ if (Platform.OS === 'web') return
32
+ getHaptics().then(h => h?.notificationAsync(h.NotificationFeedbackType.Success))
28
33
  }
29
34
 
30
35
  export function notificationError(): void {
31
- Haptics?.notificationAsync(Haptics.NotificationFeedbackType.Error)
36
+ if (Platform.OS === 'web') return
37
+ getHaptics().then(h => h?.notificationAsync(h.NotificationFeedbackType.Error))
32
38
  }
@@ -1,84 +0,0 @@
1
- import React from 'react'
2
- import { View, Text, StyleSheet, ViewStyle } from 'react-native'
3
- import { useTheme } from '../../theme'
4
- import { s, vs, ms, mvs } from '../../utils/scaling'
5
-
6
- export type AlertBannerVariant = 'default' | 'destructive' | 'success'
7
-
8
- export interface AlertBannerProps {
9
- title?: string
10
- description?: string
11
- variant?: AlertBannerVariant
12
- icon?: React.ReactNode
13
- style?: ViewStyle
14
- }
15
-
16
- export function AlertBanner({ title, description, variant = 'default', icon, style }: AlertBannerProps) {
17
- const { colors } = useTheme()
18
-
19
- const borderColor =
20
- variant === 'destructive' ? colors.destructive
21
- : variant === 'success' ? colors.success
22
- : colors.border
23
-
24
- const titleColor =
25
- variant === 'destructive' ? colors.destructive
26
- : variant === 'success' ? colors.success
27
- : colors.foreground
28
-
29
- const descColor =
30
- variant === 'destructive' ? colors.destructive
31
- : variant === 'success' ? colors.success
32
- : colors.mutedForeground
33
-
34
- const defaultIcon =
35
- variant === 'destructive' ? '⚠' : variant === 'success' ? '✓' : 'ℹ'
36
-
37
- return (
38
- <View style={[styles.container, { backgroundColor: colors.card, borderColor }, style]}>
39
- {icon ? (
40
- <View style={styles.icon}>{icon}</View>
41
- ) : (
42
- <View style={styles.icon}>
43
- <Text style={[styles.defaultIcon, { color: titleColor }]} allowFontScaling={true}>{defaultIcon}</Text>
44
- </View>
45
- )}
46
- <View style={styles.content}>
47
- {title ? <Text style={[styles.title, { color: titleColor }]} allowFontScaling={true}>{title}</Text> : null}
48
- {description ? (
49
- <Text style={[styles.description, { color: descColor }]} allowFontScaling={true}>{description}</Text>
50
- ) : null}
51
- </View>
52
- </View>
53
- )
54
- }
55
-
56
- const styles = StyleSheet.create({
57
- container: {
58
- flexDirection: 'row',
59
- borderWidth: 1,
60
- borderRadius: ms(8),
61
- padding: s(16),
62
- gap: s(12),
63
- },
64
- icon: {
65
- marginTop: vs(2),
66
- },
67
- content: {
68
- flex: 1,
69
- gap: vs(4),
70
- },
71
- title: {
72
- fontSize: ms(14),
73
- fontWeight: '500',
74
- lineHeight: mvs(20),
75
- },
76
- description: {
77
- fontSize: ms(14),
78
- lineHeight: mvs(20),
79
- },
80
- defaultIcon: {
81
- fontSize: ms(18),
82
- fontWeight: '700',
83
- },
84
- })