@retray-dev/ui-kit 9.0.0 → 9.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.
Files changed (57) hide show
  1. package/COMPONENTS.md +178 -7
  2. package/CONSUMER.md +247 -0
  3. package/DESIGN.md +668 -0
  4. package/EXAMPLES.md +19 -12
  5. package/FONTS.md +107 -0
  6. package/README.md +3 -3
  7. package/dist/AlertBanner.d.mts +3 -1
  8. package/dist/AlertBanner.d.ts +3 -1
  9. package/dist/AlertBanner.js +18 -2
  10. package/dist/AlertBanner.mjs +1 -1
  11. package/dist/ConfirmDialog.d.mts +3 -1
  12. package/dist/ConfirmDialog.d.ts +3 -1
  13. package/dist/ConfirmDialog.js +3 -0
  14. package/dist/ConfirmDialog.mjs +1 -1
  15. package/dist/CurrencyInput.d.mts +3 -1
  16. package/dist/CurrencyInput.d.ts +3 -1
  17. package/dist/CurrencyInput.js +31 -4
  18. package/dist/CurrencyInput.mjs +2 -2
  19. package/dist/ImageUpload.d.mts +27 -0
  20. package/dist/ImageUpload.d.ts +27 -0
  21. package/dist/ImageUpload.js +399 -0
  22. package/dist/ImageUpload.mjs +9 -0
  23. package/dist/Input.d.mts +3 -1
  24. package/dist/Input.d.ts +3 -1
  25. package/dist/Input.js +27 -2
  26. package/dist/Input.mjs +1 -1
  27. package/dist/ListItem.d.mts +3 -1
  28. package/dist/ListItem.d.ts +3 -1
  29. package/dist/ListItem.js +2 -1
  30. package/dist/ListItem.mjs +1 -1
  31. package/dist/SheetSelect.d.mts +25 -0
  32. package/dist/SheetSelect.d.ts +25 -0
  33. package/dist/SheetSelect.js +440 -0
  34. package/dist/SheetSelect.mjs +9 -0
  35. package/dist/{chunk-M6ZXVBTK.mjs → chunk-6MKGPAR2.mjs} +21 -5
  36. package/dist/{chunk-7QHVVCB3.mjs → chunk-FZZLPJ6B.mjs} +3 -0
  37. package/dist/{chunk-MAC465BB.mjs → chunk-KNSENOV4.mjs} +5 -3
  38. package/dist/{chunk-756RAKE4.mjs → chunk-LVYEU5ZK.mjs} +27 -2
  39. package/dist/{chunk-BNP626TY.mjs → chunk-T4I5WVHA.mjs} +2 -1
  40. package/dist/chunk-URI2WBIV.mjs +147 -0
  41. package/dist/chunk-Y4GL2MHX.mjs +112 -0
  42. package/dist/index.d.mts +26 -1
  43. package/dist/index.d.ts +26 -1
  44. package/dist/index.js +327 -8
  45. package/dist/index.mjs +51 -12
  46. package/package.json +18 -5
  47. package/src/components/AlertBanner/AlertBanner.tsx +21 -3
  48. package/src/components/ConfirmDialog/ConfirmDialog.tsx +5 -0
  49. package/src/components/CurrencyInput/CurrencyInput.tsx +4 -0
  50. package/src/components/ImageUpload/ImageUpload.tsx +158 -0
  51. package/src/components/ImageUpload/index.ts +1 -0
  52. package/src/components/Input/Input.tsx +51 -23
  53. package/src/components/ListItem/ListItem.tsx +4 -1
  54. package/src/components/SheetSelect/SheetSelect.tsx +192 -0
  55. package/src/components/SheetSelect/index.ts +1 -0
  56. package/src/hooks/useConfirmDialog.ts +67 -0
  57. package/src/index.ts +6 -0
@@ -19,6 +19,8 @@ export interface ConfirmDialogProps {
19
19
  confirmLabel?: string
20
20
  cancelLabel?: string
21
21
  confirmVariant?: 'primary' | 'destructive'
22
+ /** Show a loading spinner in the confirm button (e.g. while async action completes). */
23
+ loading?: boolean
22
24
  onConfirm: () => void
23
25
  onCancel: () => void
24
26
  }
@@ -30,6 +32,7 @@ export function ConfirmDialog({
30
32
  confirmLabel = 'Confirm',
31
33
  cancelLabel = 'Cancel',
32
34
  confirmVariant = 'primary',
35
+ loading = false,
33
36
  onConfirm,
34
37
  onCancel,
35
38
  }: ConfirmDialogProps) {
@@ -78,6 +81,8 @@ export function ConfirmDialog({
78
81
  label={confirmLabel}
79
82
  variant={confirmVariant}
80
83
  fullWidth
84
+ loading={loading}
85
+ disabled={loading}
81
86
  onPress={() => { notificationSuccess(); onConfirm() }}
82
87
  icon={
83
88
  <Feather
@@ -22,6 +22,8 @@ export interface CurrencyInputProps {
22
22
  editable?: boolean
23
23
  containerStyle?: ViewStyle
24
24
  style?: TextStyle
25
+ /** Use inside a Sheet/BottomSheet — passes sheetMode to the underlying Input. */
26
+ sheetMode?: boolean
25
27
  }
26
28
 
27
29
  function formatCurrency(raw: string, separator: '.' | ','): string {
@@ -44,6 +46,7 @@ export function CurrencyInput({
44
46
  editable,
45
47
  containerStyle,
46
48
  style,
49
+ sheetMode,
47
50
  }: CurrencyInputProps) {
48
51
  const handleChange = (text: string) => {
49
52
  const withoutPrefix = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text
@@ -83,6 +86,7 @@ export function CurrencyInput({
83
86
  containerStyle={containerStyle}
84
87
  inputWrapperStyle={isLarge ? { paddingVertical: vs(16), minHeight: 72 } : undefined}
85
88
  style={[inputStyle, style]}
89
+ sheetMode={sheetMode}
86
90
  />
87
91
  )
88
92
  }
@@ -0,0 +1,158 @@
1
+ import React from 'react'
2
+ import { View, Text, Image, StyleSheet, ViewStyle, Platform } from 'react-native'
3
+ import { Feather } from '@expo/vector-icons'
4
+ import { impactLight } from '../../utils/haptics'
5
+ import { useTheme } from '../../theme'
6
+ import { s, vs, ms } from '../../utils/scaling'
7
+ import { RADIUS } from '../../tokens'
8
+ import { Spinner } from '../Spinner'
9
+ import { PressableCard } from '../../utils/pressable'
10
+
11
+ export interface ImageUploadProps {
12
+ /** Current image URI. Pass null/undefined to show placeholder. */
13
+ value?: string | null
14
+ /** Called with the selected image URI. Receives null when image is cleared. */
15
+ onChange?: (uri: string | null) => void
16
+ /** Show a loading spinner over the image (e.g. while uploading). */
17
+ loading?: boolean
18
+ /** Text shown when no image is selected. */
19
+ placeholder?: string
20
+ /** Width of the upload area. Defaults to full width (undefined). */
21
+ width?: number
22
+ /** Height of the upload area. Defaults to 200. */
23
+ height?: number
24
+ /** Border radius of the upload area. Defaults to RADIUS.lg. */
25
+ borderRadius?: number
26
+ /** Aspect ratio for the selected image. Defaults to 'cover'. */
27
+ resizeMode?: 'cover' | 'contain' | 'stretch'
28
+ disabled?: boolean
29
+ style?: ViewStyle
30
+ accessibilityLabel?: string
31
+ }
32
+
33
+ export function ImageUpload({
34
+ value,
35
+ onChange,
36
+ loading = false,
37
+ placeholder = 'Tap to add image',
38
+ width,
39
+ height = 200,
40
+ borderRadius = RADIUS.lg,
41
+ resizeMode = 'cover',
42
+ disabled = false,
43
+ style,
44
+ accessibilityLabel,
45
+ }: ImageUploadProps) {
46
+ const { colors } = useTheme()
47
+
48
+ const handlePress = async () => {
49
+ if (disabled || loading) return
50
+ impactLight()
51
+
52
+ // Dynamic import so expo-image-picker is optional at module load time.
53
+ // Consumers who don't use ImageUpload never pull this dep.
54
+ let ImagePicker: typeof import('expo-image-picker')
55
+ try {
56
+ ImagePicker = await import('expo-image-picker')
57
+ } catch {
58
+ if (__DEV__) console.warn('[ImageUpload] expo-image-picker not installed. Add it as a dependency.')
59
+ return
60
+ }
61
+
62
+ if (Platform.OS !== 'web') {
63
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
64
+ if (status !== 'granted') return
65
+ }
66
+
67
+ const result = await ImagePicker.launchImageLibraryAsync({
68
+ mediaTypes: ['images'],
69
+ allowsEditing: true,
70
+ quality: 0.8,
71
+ })
72
+
73
+ if (!result.canceled && result.assets[0]) {
74
+ onChange?.(result.assets[0].uri)
75
+ }
76
+ }
77
+
78
+ const containerStyle: ViewStyle = {
79
+ width,
80
+ height,
81
+ borderRadius,
82
+ borderWidth: value ? 0 : 1,
83
+ borderStyle: 'dashed',
84
+ borderColor: colors.border,
85
+ backgroundColor: value ? 'transparent' : colors.surface,
86
+ overflow: 'hidden',
87
+ }
88
+
89
+ return (
90
+ <PressableCard
91
+ onPress={handlePress}
92
+ enabled={!disabled && !loading}
93
+ rippleColor="transparent"
94
+ touchSoundDisabled
95
+ accessibilityRole="button"
96
+ accessibilityLabel={accessibilityLabel ?? (value ? 'Change image' : placeholder)}
97
+ accessibilityState={{ disabled: disabled || loading }}
98
+ style={[containerStyle, style]}
99
+ >
100
+ {value ? (
101
+ <Image
102
+ source={{ uri: value }}
103
+ style={[StyleSheet.absoluteFillObject, { borderRadius }]}
104
+ resizeMode={resizeMode}
105
+ />
106
+ ) : (
107
+ <View style={styles.placeholder}>
108
+ <Feather name="image" size={ms(28)} color={colors.foregroundMuted} />
109
+ <Text style={[styles.placeholderText, { color: colors.foregroundMuted }]} allowFontScaling={true}>
110
+ {placeholder}
111
+ </Text>
112
+ </View>
113
+ )}
114
+ {loading ? (
115
+ <View style={[styles.loadingOverlay, { backgroundColor: colors.overlay }]}>
116
+ <Spinner size="md" />
117
+ </View>
118
+ ) : null}
119
+ {value && !loading ? (
120
+ <View style={styles.editBadge} pointerEvents="none">
121
+ <View style={[styles.editBadgeInner, { backgroundColor: colors.overlay }]}>
122
+ <Feather name="edit-2" size={ms(12)} color="#fff" />
123
+ </View>
124
+ </View>
125
+ ) : null}
126
+ </PressableCard>
127
+ )
128
+ }
129
+
130
+ const styles = StyleSheet.create({
131
+ placeholder: {
132
+ flex: 1,
133
+ alignItems: 'center',
134
+ justifyContent: 'center',
135
+ gap: vs(8),
136
+ },
137
+ placeholderText: {
138
+ fontFamily: 'Sohne-Regular',
139
+ fontSize: ms(13),
140
+ },
141
+ loadingOverlay: {
142
+ ...StyleSheet.absoluteFillObject,
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ },
146
+ editBadge: {
147
+ position: 'absolute',
148
+ bottom: vs(8),
149
+ right: s(8),
150
+ },
151
+ editBadgeInner: {
152
+ width: s(28),
153
+ height: s(28),
154
+ borderRadius: 999,
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ },
158
+ })
@@ -0,0 +1 @@
1
+ export * from './ImageUpload'
@@ -1,5 +1,6 @@
1
1
  import React, { useState } from 'react'
2
2
  import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle, TextStyle, TouchableOpacity, Platform } from 'react-native'
3
+ import { BottomSheetTextInput } from '@gorhom/bottom-sheet'
3
4
  import Animated, {
4
5
  useAnimatedStyle,
5
6
  interpolateColor,
@@ -33,9 +34,11 @@ export interface InputProps extends TextInputProps {
33
34
  type?: 'text' | 'password'
34
35
  containerStyle?: ViewStyle
35
36
  inputWrapperStyle?: ViewStyle
37
+ /** Use inside a Sheet/BottomSheet — swaps TextInput for BottomSheetTextInput to fix keyboard handling. */
38
+ sheetMode?: boolean
36
39
  }
37
40
 
38
- export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyle, suffixStyle, prefixIcon, suffixIcon, prefixIconColor, suffixIconColor, type = 'text', containerStyle, inputWrapperStyle, style, onFocus, onBlur, secureTextEntry, editable, accessibilityLabel, ...props }: InputProps) {
41
+ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyle, suffixStyle, prefixIcon, suffixIcon, prefixIconColor, suffixIconColor, type = 'text', containerStyle, inputWrapperStyle, sheetMode = false, style, onFocus, onBlur, secureTextEntry, editable, accessibilityLabel, ...props }: InputProps) {
39
42
  const { colors } = useTheme()
40
43
  const [focused, setFocused] = useState(false)
41
44
  const [showPassword, setShowPassword] = useState(false)
@@ -100,28 +103,53 @@ export function Input({ label, error, hint, disabled, prefix, suffix, prefixStyl
100
103
  <View style={styles.prefixContainer}>{effectivePrefix}</View>
101
104
  )
102
105
  ) : null}
103
- <TextInput
104
- style={[
105
- styles.input,
106
- { color: colors.foreground },
107
- webInputResetStyle,
108
- style,
109
- ]}
110
- onFocus={(e) => {
111
- setFocused(true)
112
- onFocus?.(e)
113
- }}
114
- onBlur={(e) => {
115
- setFocused(false)
116
- onBlur?.(e)
117
- }}
118
- placeholderTextColor={colors.foregroundMuted}
119
- allowFontScaling={true}
120
- secureTextEntry={effectiveSecure}
121
- editable={isDisabled ? false : editable}
122
- accessibilityLabel={accessibilityLabel ?? label}
123
- {...props}
124
- />
106
+ {sheetMode ? (
107
+ <BottomSheetTextInput
108
+ style={[
109
+ styles.input,
110
+ { color: colors.foreground },
111
+ webInputResetStyle as TextStyle,
112
+ style,
113
+ ]}
114
+ onFocus={(e) => {
115
+ setFocused(true)
116
+ onFocus?.(e)
117
+ }}
118
+ onBlur={(e) => {
119
+ setFocused(false)
120
+ onBlur?.(e)
121
+ }}
122
+ placeholderTextColor={colors.foregroundMuted}
123
+ allowFontScaling={true}
124
+ secureTextEntry={effectiveSecure}
125
+ editable={isDisabled ? false : editable}
126
+ accessibilityLabel={accessibilityLabel ?? label}
127
+ {...props}
128
+ />
129
+ ) : (
130
+ <TextInput
131
+ style={[
132
+ styles.input,
133
+ { color: colors.foreground },
134
+ webInputResetStyle,
135
+ style,
136
+ ]}
137
+ onFocus={(e) => {
138
+ setFocused(true)
139
+ onFocus?.(e)
140
+ }}
141
+ onBlur={(e) => {
142
+ setFocused(false)
143
+ onBlur?.(e)
144
+ }}
145
+ placeholderTextColor={colors.foregroundMuted}
146
+ allowFontScaling={true}
147
+ secureTextEntry={effectiveSecure}
148
+ editable={isDisabled ? false : editable}
149
+ accessibilityLabel={accessibilityLabel ?? label}
150
+ {...props}
151
+ />
152
+ )}
125
153
  {effectiveSuffix ? (
126
154
  typeof effectiveSuffix === 'string' ? (
127
155
  <Text style={[styles.suffixText, { color: colors.foregroundMuted }, suffixStyle]} allowFontScaling={true}>
@@ -72,6 +72,8 @@ export interface ListItemProps {
72
72
  titleStyle?: TextStyle
73
73
  /** Style applied to the subtitle Text. */
74
74
  subtitleStyle?: TextStyle
75
+ /** Max lines for the subtitle. Defaults to 2. */
76
+ subtitleNumberOfLines?: number
75
77
  /** Style applied to the caption Text. */
76
78
  captionStyle?: TextStyle
77
79
  /** Accessibility label override. Defaults to the title. */
@@ -98,6 +100,7 @@ function ListItemBase({
98
100
  style,
99
101
  titleStyle,
100
102
  subtitleStyle,
103
+ subtitleNumberOfLines = 2,
101
104
  captionStyle,
102
105
  accessibilityLabel,
103
106
  }: ListItemProps) {
@@ -150,7 +153,7 @@ function ListItemBase({
150
153
  {subtitle ? (
151
154
  <Text
152
155
  style={[styles.subtitle, { color: colors.foregroundMuted }, subtitleStyle]}
153
- numberOfLines={2}
156
+ numberOfLines={subtitleNumberOfLines}
154
157
  allowFontScaling={true}
155
158
  >
156
159
  {subtitle}
@@ -0,0 +1,192 @@
1
+ import React from 'react'
2
+ import { View, Text, ScrollView, StyleSheet, ViewStyle } from 'react-native'
3
+ import { EaseView } from 'react-native-ease'
4
+ import { selectionAsync as hapticSelection } from '../../utils/haptics'
5
+ import { useTheme } from '../../theme'
6
+ import { s, vs, ms, mvs } from '../../utils/scaling'
7
+ import { renderIcon } from '../../utils/icons'
8
+ import { COLOR_TRANSITION } from '../../utils/animations'
9
+ import { PressableChip } from '../../utils/pressable'
10
+ import { RADIUS } from '../../tokens'
11
+
12
+ export interface SheetSelectOption {
13
+ label: string
14
+ value: string | number
15
+ iconName?: string
16
+ disabled?: boolean
17
+ }
18
+
19
+ export interface SheetSelectProps {
20
+ options: SheetSelectOption[]
21
+ value?: string | number | (string | number)[]
22
+ onValueChange?: (value: string | number | (string | number)[]) => void
23
+ /** Allow multiple simultaneous selections. Defaults to false (radio). */
24
+ multiSelect?: boolean
25
+ label?: string
26
+ error?: string
27
+ /** Wrap chips into multiple rows instead of a single horizontal scroll. Defaults to false. */
28
+ wrap?: boolean
29
+ style?: ViewStyle
30
+ accessibilityLabel?: string
31
+ }
32
+
33
+ function SheetSelectChip({
34
+ option,
35
+ selected,
36
+ onPress,
37
+ }: {
38
+ option: SheetSelectOption
39
+ selected: boolean
40
+ onPress: () => void
41
+ }) {
42
+ const { colors } = useTheme()
43
+
44
+ const handlePress = () => {
45
+ hapticSelection()
46
+ onPress()
47
+ }
48
+
49
+ const iconColor = selected ? colors.primaryForeground : colors.foreground
50
+ const resolvedIcon = option.iconName ? renderIcon(option.iconName, ms(13), iconColor) : null
51
+
52
+ return (
53
+ <PressableChip
54
+ onPress={option.disabled ? undefined : handlePress}
55
+ rippleColor="transparent"
56
+ touchSoundDisabled
57
+ accessibilityRole="button"
58
+ accessibilityLabel={option.disabled ? `${option.label}, unavailable` : option.label}
59
+ accessibilityState={{ selected, disabled: option.disabled }}
60
+ >
61
+ <EaseView
62
+ style={[styles.chip, option.disabled && styles.chipDisabled]}
63
+ animate={{
64
+ backgroundColor: selected ? colors.primary : colors.surface,
65
+ borderColor: selected ? colors.primary : colors.border,
66
+ }}
67
+ transition={COLOR_TRANSITION}
68
+ >
69
+ {resolvedIcon ? <View style={styles.chipIcon}>{resolvedIcon}</View> : null}
70
+ <Text
71
+ style={[styles.chipLabel, { color: selected ? colors.primaryForeground : colors.foreground }]}
72
+ allowFontScaling={true}
73
+ >
74
+ {option.label}
75
+ </Text>
76
+ </EaseView>
77
+ </PressableChip>
78
+ )
79
+ }
80
+
81
+ export function SheetSelect({
82
+ options,
83
+ value,
84
+ onValueChange,
85
+ multiSelect = false,
86
+ label,
87
+ error,
88
+ wrap = false,
89
+ style,
90
+ accessibilityLabel,
91
+ }: SheetSelectProps) {
92
+ const { colors } = useTheme()
93
+
94
+ const isSelected = (optionValue: string | number): boolean => {
95
+ if (Array.isArray(value)) return value.includes(optionValue)
96
+ return optionValue === value
97
+ }
98
+
99
+ const handlePress = (optionValue: string | number) => {
100
+ if (!multiSelect) {
101
+ onValueChange?.(optionValue)
102
+ return
103
+ }
104
+ const currentArray = Array.isArray(value) ? value : value != null ? [value] : []
105
+ const alreadySelected = currentArray.includes(optionValue)
106
+ const newArray: (string | number)[] = alreadySelected
107
+ ? currentArray.filter((v) => v !== optionValue)
108
+ : [...currentArray, optionValue]
109
+ onValueChange?.(newArray)
110
+ }
111
+
112
+ const chips = options.map((opt) => (
113
+ <SheetSelectChip
114
+ key={opt.value}
115
+ option={opt}
116
+ selected={isSelected(opt.value)}
117
+ onPress={() => handlePress(opt.value)}
118
+ />
119
+ ))
120
+
121
+ return (
122
+ <View style={[styles.container, style]} accessibilityLabel={accessibilityLabel}>
123
+ {label ? (
124
+ <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>
125
+ {label}
126
+ </Text>
127
+ ) : null}
128
+ {wrap ? (
129
+ <View style={styles.wrapContainer}>{chips}</View>
130
+ ) : (
131
+ <ScrollView
132
+ horizontal
133
+ showsHorizontalScrollIndicator={false}
134
+ contentContainerStyle={styles.scrollContent}
135
+ >
136
+ {chips}
137
+ </ScrollView>
138
+ )}
139
+ {error ? (
140
+ <Text style={[styles.error, { color: colors.destructive }]} allowFontScaling={true} accessibilityLiveRegion="polite">
141
+ {error}
142
+ </Text>
143
+ ) : null}
144
+ </View>
145
+ )
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ container: {
150
+ gap: vs(8),
151
+ },
152
+ label: {
153
+ fontFamily: 'Sohne-Medium',
154
+ fontSize: ms(14),
155
+ },
156
+ scrollContent: {
157
+ flexDirection: 'row',
158
+ gap: s(8),
159
+ },
160
+ wrapContainer: {
161
+ flexDirection: 'row',
162
+ flexWrap: 'wrap',
163
+ gap: s(8),
164
+ },
165
+ chip: {
166
+ borderRadius: RADIUS.full,
167
+ paddingHorizontal: s(14),
168
+ paddingVertical: vs(10),
169
+ minHeight: 44,
170
+ borderWidth: 1,
171
+ alignItems: 'center',
172
+ justifyContent: 'center',
173
+ flexDirection: 'row',
174
+ gap: s(5),
175
+ },
176
+ chipDisabled: {
177
+ opacity: 0.4,
178
+ },
179
+ chipIcon: {
180
+ alignItems: 'center',
181
+ justifyContent: 'center',
182
+ },
183
+ chipLabel: {
184
+ fontFamily: 'Sohne-Medium',
185
+ fontSize: ms(13),
186
+ lineHeight: mvs(18),
187
+ },
188
+ error: {
189
+ fontFamily: 'Sohne-Regular',
190
+ fontSize: ms(13),
191
+ },
192
+ })
@@ -0,0 +1 @@
1
+ export * from './SheetSelect'
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ export interface UseConfirmDialogOptions {
4
+ onConfirm: () => void | Promise<void>
5
+ onCancel?: () => void
6
+ }
7
+
8
+ export interface UseConfirmDialogResult<T> {
9
+ /** Pass to ConfirmDialog `visible` prop. */
10
+ visible: boolean
11
+ /** The value passed to `open()` — available during the confirmation flow. */
12
+ target: T | null
13
+ /** Whether `onConfirm` is currently executing. Pass to ConfirmDialog `loading` prop. */
14
+ loading: boolean
15
+ /** Open the dialog, optionally with an associated value (e.g. the item to delete). */
16
+ open: (target?: T) => void
17
+ /** Handlers to pass directly to ConfirmDialog. */
18
+ dialogProps: {
19
+ visible: boolean
20
+ loading: boolean
21
+ onConfirm: () => void
22
+ onCancel: () => void
23
+ }
24
+ }
25
+
26
+ export function useConfirmDialog<T = undefined>(
27
+ options: UseConfirmDialogOptions,
28
+ ): UseConfirmDialogResult<T> {
29
+ const [visible, setVisible] = useState(false)
30
+ const [target, setTarget] = useState<T | null>(null)
31
+ const [loading, setLoading] = useState(false)
32
+
33
+ const open = useCallback((t?: T) => {
34
+ setTarget(t ?? null)
35
+ setVisible(true)
36
+ }, [])
37
+
38
+ const handleConfirm = useCallback(async () => {
39
+ setLoading(true)
40
+ try {
41
+ await options.onConfirm()
42
+ } finally {
43
+ setLoading(false)
44
+ setVisible(false)
45
+ setTarget(null)
46
+ }
47
+ }, [options])
48
+
49
+ const handleCancel = useCallback(() => {
50
+ setVisible(false)
51
+ setTarget(null)
52
+ options.onCancel?.()
53
+ }, [options])
54
+
55
+ return {
56
+ visible,
57
+ target,
58
+ loading,
59
+ open,
60
+ dialogProps: {
61
+ visible,
62
+ loading,
63
+ onConfirm: handleConfirm,
64
+ onCancel: handleCancel,
65
+ },
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -53,6 +53,8 @@ export * from './components/SelectableGrid'
53
53
  export * from './components/PricingCard'
54
54
  export * from './components/TabBar'
55
55
  export * from './components/ImageViewer'
56
+ export * from './components/SheetSelect'
57
+ export * from './components/ImageUpload'
56
58
  // HolographicCard is intentionally NOT re-exported here — it depends on the
57
59
  // optional peer @shopify/react-native-skia, so it must stay out of the main
58
60
  // barrel's module graph. Deep-import it: '@retray-dev/ui-kit/HolographicCard'.
@@ -76,6 +78,10 @@ export {
76
78
  richHaptics,
77
79
  } from './utils/haptics'
78
80
 
81
+ // Hooks
82
+ export { useConfirmDialog } from './hooks/useConfirmDialog'
83
+ export type { UseConfirmDialogOptions, UseConfirmDialogResult } from './hooks/useConfirmDialog'
84
+
79
85
  // Design tokens
80
86
  export {
81
87
  SPACING,