@retray-dev/ui-kit 7.0.1 → 9.1.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 +567 -14
- package/EXAMPLES.md +21 -14
- package/README.md +14 -8
- package/dist/Accordion.js +57 -5
- package/dist/Accordion.mjs +4 -3
- package/dist/AlertBanner.js +4 -1
- package/dist/AlertBanner.mjs +3 -2
- package/dist/AppHeader.d.mts +40 -0
- package/dist/AppHeader.d.ts +40 -0
- package/dist/AppHeader.js +515 -0
- package/dist/AppHeader.mjs +10 -0
- package/dist/Avatar.js +39 -29
- package/dist/Avatar.mjs +2 -1
- package/dist/Badge.js +11 -1
- package/dist/Badge.mjs +2 -1
- package/dist/Button.d.mts +8 -3
- package/dist/Button.d.ts +8 -3
- package/dist/Button.js +126 -108
- package/dist/Button.mjs +6 -5
- package/dist/ButtonGroup.mjs +1 -0
- package/dist/Card.js +90 -70
- package/dist/Card.mjs +5 -4
- package/dist/CategoryStrip.js +79 -22
- package/dist/CategoryStrip.mjs +6 -6
- package/dist/Checkbox.js +118 -86
- package/dist/Checkbox.mjs +5 -5
- package/dist/Chip.js +113 -80
- package/dist/Chip.mjs +5 -5
- package/dist/ConfirmDialog.js +140 -110
- package/dist/ConfirmDialog.mjs +7 -6
- package/dist/CurrencyDisplay.mjs +1 -0
- package/dist/CurrencyInput.d.mts +1 -1
- package/dist/CurrencyInput.d.ts +1 -1
- package/dist/CurrencyInput.js +9 -5
- package/dist/CurrencyInput.mjs +5 -4
- package/dist/DetailRow.mjs +1 -0
- package/dist/EmptyState.js +131 -111
- package/dist/EmptyState.mjs +7 -6
- package/dist/ErrorBoundary.d.mts +42 -0
- package/dist/ErrorBoundary.d.ts +42 -0
- package/dist/ErrorBoundary.js +351 -0
- package/dist/ErrorBoundary.mjs +7 -0
- package/dist/Form.mjs +1 -0
- package/dist/HolographicCard.d.mts +55 -0
- package/dist/HolographicCard.d.ts +55 -0
- package/dist/HolographicCard.js +316 -0
- package/dist/HolographicCard.mjs +191 -0
- package/dist/IconButton.d.mts +8 -3
- package/dist/IconButton.d.ts +8 -3
- package/dist/IconButton.js +115 -98
- package/dist/IconButton.mjs +5 -4
- package/dist/ImageViewer.d.mts +23 -0
- package/dist/ImageViewer.d.ts +23 -0
- package/dist/ImageViewer.js +582 -0
- package/dist/ImageViewer.mjs +8 -0
- package/dist/Input.mjs +4 -3
- package/dist/LabelValue.mjs +1 -0
- package/dist/ListGroup.mjs +1 -0
- package/dist/ListItem.js +131 -117
- package/dist/ListItem.mjs +6 -5
- package/dist/MediaCard.js +54 -6
- package/dist/MediaCard.mjs +6 -5
- package/dist/MenuGroup.mjs +1 -0
- package/dist/MenuItem.js +91 -79
- package/dist/MenuItem.mjs +6 -5
- package/dist/MonthPicker.d.mts +10 -2
- package/dist/MonthPicker.d.ts +10 -2
- package/dist/MonthPicker.js +80 -17
- package/dist/MonthPicker.mjs +3 -2
- package/dist/PagerDots.d.mts +35 -0
- package/dist/PagerDots.d.ts +35 -0
- package/dist/PagerDots.js +392 -0
- package/dist/PagerDots.mjs +7 -0
- package/dist/Pressable.d.mts +5 -5
- package/dist/Pressable.d.ts +5 -5
- package/dist/Pressable.js +97 -86
- package/dist/Pressable.mjs +5 -4
- package/dist/PricingCard.d.mts +50 -0
- package/dist/PricingCard.d.ts +50 -0
- package/dist/PricingCard.js +636 -0
- package/dist/PricingCard.mjs +11 -0
- package/dist/Progress.mjs +3 -2
- package/dist/RadioGroup.js +81 -30
- package/dist/RadioGroup.mjs +5 -5
- package/dist/RetrayProvider.d.mts +2 -0
- package/dist/RetrayProvider.d.ts +2 -0
- package/dist/RetrayProvider.js +214 -0
- package/dist/RetrayProvider.mjs +5 -0
- package/dist/Select.js +51 -4
- package/dist/Select.mjs +5 -4
- package/dist/SelectableGrid.d.mts +44 -0
- package/dist/SelectableGrid.d.ts +44 -0
- package/dist/SelectableGrid.js +448 -0
- package/dist/SelectableGrid.mjs +9 -0
- package/dist/Separator.mjs +1 -0
- package/dist/Sheet.d.mts +13 -1
- package/dist/Sheet.d.ts +13 -1
- package/dist/Sheet.js +115 -5
- package/dist/Sheet.mjs +4 -2
- package/dist/Skeleton.d.mts +50 -0
- package/dist/Skeleton.d.ts +50 -0
- package/dist/Skeleton.js +61 -0
- package/dist/Skeleton.mjs +4 -2
- package/dist/Slider.js +51 -4
- package/dist/Slider.mjs +3 -2
- package/dist/Spinner.js +28 -7
- package/dist/Spinner.mjs +2 -1
- package/dist/Switch.js +98 -48
- package/dist/Switch.mjs +4 -3
- package/dist/TabBar.d.mts +42 -0
- package/dist/TabBar.d.ts +42 -0
- package/dist/TabBar.js +361 -0
- package/dist/TabBar.mjs +6 -0
- package/dist/Tabs.js +92 -62
- package/dist/Tabs.mjs +5 -4
- package/dist/Text.js +16 -0
- package/dist/Text.mjs +2 -1
- package/dist/Textarea.mjs +4 -3
- package/dist/Toast.d.mts +7 -7
- package/dist/Toast.d.ts +7 -7
- package/dist/Toast.mjs +1 -0
- package/dist/Toggle.d.mts +6 -3
- package/dist/Toggle.d.ts +6 -3
- package/dist/Toggle.js +135 -120
- package/dist/Toggle.mjs +5 -5
- package/dist/VirtualList.mjs +1 -0
- package/dist/{chunk-7H2OR44A.mjs → chunk-26BCI223.mjs} +1 -1
- package/dist/{chunk-CRYBX2CM.mjs → chunk-2TFTAWVJ.mjs} +44 -59
- package/dist/chunk-3DKJ2GIC.mjs +30 -0
- package/dist/{chunk-KWCPOM6W.mjs → chunk-3U4SSNWP.mjs} +32 -48
- package/dist/chunk-4I7D47FH.mjs +139 -0
- package/dist/chunk-4K625MVM.mjs +142 -0
- package/dist/{chunk-MN7OG7IY.mjs → chunk-6OAZJ577.mjs} +6 -4
- package/dist/{chunk-L7E7TVEZ.mjs → chunk-756RAKE4.mjs} +2 -2
- package/dist/{chunk-HSPSMN6U.mjs → chunk-7QHVVCB3.mjs} +2 -2
- package/dist/{chunk-URLL5JBR.mjs → chunk-A3A6KNQN.mjs} +3 -3
- package/dist/chunk-AJ7ZDNBT.mjs +120 -0
- package/dist/{chunk-FTLJOUOQ.mjs → chunk-AV4EMIRH.mjs} +25 -28
- package/dist/chunk-AZJF2BLK.mjs +115 -0
- package/dist/chunk-BNP626TY.mjs +159 -0
- package/dist/{chunk-5IKW3VNC.mjs → chunk-DVK4G2GT.mjs} +17 -1
- package/dist/{chunk-6LQYY7HC.mjs → chunk-EH745HE5.mjs} +2 -2
- package/dist/chunk-EJ7ZPXOH.mjs +163 -0
- package/dist/{chunk-RKLHUDZS.mjs → chunk-GD6KXMG5.mjs} +29 -15
- package/dist/{chunk-RR2VQLKE.mjs → chunk-GQYFLP3D.mjs} +14 -17
- package/dist/{chunk-Y6MXOREN.mjs → chunk-ID72TK46.mjs} +8 -17
- package/dist/{chunk-NQGVLMWG.mjs → chunk-JMOZEC77.mjs} +1 -1
- package/dist/{chunk-GCWOGZYL.mjs → chunk-JT7HKXRB.mjs} +39 -29
- package/dist/{chunk-LWG526VX.mjs → chunk-KIHCWCWL.mjs} +47 -62
- package/dist/chunk-LXJIIOYQ.mjs +104 -0
- package/dist/{chunk-SBZYEV4S.mjs → chunk-M6ZXVBTK.mjs} +5 -2
- package/dist/{chunk-XDMN67KV.mjs → chunk-MAC465BB.mjs} +10 -8
- package/dist/chunk-MBMXYJJV.mjs +36 -0
- package/dist/chunk-MLF3EZFW.mjs +119 -0
- package/dist/chunk-NA7PARID.mjs +147 -0
- package/dist/{chunk-QXGYKWI7.mjs → chunk-O3HA6TYM.mjs} +9 -4
- package/dist/{chunk-63357L2X.mjs → chunk-OB4JUQ3O.mjs} +1 -1
- package/dist/{chunk-AU2VDY4P.mjs → chunk-PFZTM6D5.mjs} +52 -4
- package/dist/chunk-QKH5ZOD5.mjs +97 -0
- package/dist/{chunk-KZJRQOIU.mjs → chunk-TERDKCLE.mjs} +11 -1
- package/dist/{chunk-U4N7WF4Z.mjs → chunk-UREA2GYY.mjs} +28 -23
- package/dist/{chunk-TAJ2PQ2O.mjs → chunk-VGTDN7SW.mjs} +7 -6
- package/dist/{chunk-URDE3EUU.mjs → chunk-VQ57HWPL.mjs} +27 -15
- package/dist/chunk-WBOOUHSS.mjs +62 -0
- package/dist/{chunk-GNGLDL6Z.mjs → chunk-WJLKJMKR.mjs} +18 -0
- package/dist/{chunk-YZJAFS4P.mjs → chunk-X4G6APW6.mjs} +22 -19
- package/dist/chunk-Y6FXYEAI.mjs +8 -0
- package/dist/chunk-YFZ3ELX5.mjs +16 -0
- package/dist/{chunk-QCNARS3X.mjs → chunk-YNROWHQJ.mjs} +1 -1
- package/dist/chunk-Z4BVUWW6.mjs +196 -0
- package/dist/{chunk-GPOUINK5.mjs → chunk-ZJKGQMYH.mjs} +10 -27
- package/dist/index-wt-orHUi.d.mts +85 -0
- package/dist/index-wt-orHUi.d.ts +85 -0
- package/dist/index.d.mts +59 -51
- package/dist/index.d.ts +59 -51
- package/dist/index.js +1940 -744
- package/dist/index.mjs +49 -39
- package/package.json +35 -5
- package/src/components/Accordion/Accordion.tsx +12 -1
- package/src/components/AlertBanner/AlertBanner.tsx +5 -0
- package/src/components/AppHeader/AppHeader.tsx +172 -0
- package/src/components/AppHeader/index.ts +1 -0
- package/src/components/Avatar/Avatar.tsx +10 -2
- package/src/components/Badge/Badge.tsx +8 -1
- package/src/components/Button/Button.tsx +20 -27
- package/src/components/Card/Card.tsx +12 -23
- package/src/components/CategoryStrip/CategoryStrip.tsx +17 -21
- package/src/components/Checkbox/Checkbox.tsx +26 -40
- package/src/components/Chip/Chip.tsx +24 -33
- package/src/components/CurrencyInput/CurrencyInput.tsx +10 -8
- package/src/components/EmptyState/EmptyState.tsx +2 -1
- package/src/components/ErrorBoundary/ErrorBoundary.tsx +153 -0
- package/src/components/ErrorBoundary/index.ts +1 -0
- package/src/components/HolographicCard/HolographicCard.tsx +315 -0
- package/src/components/HolographicCard/index.ts +1 -0
- package/src/components/IconButton/IconButton.tsx +19 -27
- package/src/components/ImageViewer/ImageViewer.tsx +290 -0
- package/src/components/ImageViewer/index.ts +1 -0
- package/src/components/ListItem/ListItem.tsx +70 -67
- package/src/components/MediaCard/MediaCard.tsx +8 -2
- package/src/components/MenuItem/MenuItem.tsx +10 -25
- package/src/components/MonthPicker/MonthPicker.tsx +39 -13
- package/src/components/MonthPicker/index.ts +1 -1
- package/src/components/PagerDots/PagerDots.tsx +200 -0
- package/src/components/PagerDots/index.ts +1 -0
- package/src/components/Pressable/Pressable.tsx +19 -35
- package/src/components/PricingCard/PricingCard.tsx +220 -0
- package/src/components/PricingCard/index.ts +1 -0
- package/src/components/RadioGroup/RadioGroup.tsx +14 -27
- package/src/components/RetrayProvider/RetrayProvider.tsx +59 -0
- package/src/components/RetrayProvider/index.ts +1 -0
- package/src/components/SelectableGrid/SelectableGrid.tsx +205 -0
- package/src/components/SelectableGrid/index.ts +1 -0
- package/src/components/Sheet/Sheet.tsx +65 -1
- package/src/components/Skeleton/Skeleton.tsx +142 -1
- package/src/components/Spinner/Spinner.tsx +17 -2
- package/src/components/Switch/Switch.tsx +30 -58
- package/src/components/TabBar/TabBar.tsx +169 -0
- package/src/components/TabBar/index.ts +1 -0
- package/src/components/Tabs/Tabs.tsx +23 -26
- package/src/components/Text/Text.tsx +2 -0
- package/src/components/Toggle/Toggle.tsx +35 -51
- package/src/fonts.ts +4 -1
- package/src/index.ts +23 -2
- package/src/utils/animations.ts +29 -1
- package/src/utils/fontGuard.ts +34 -0
- package/src/utils/haptics.ts +211 -9
- package/src/utils/pressable.ts +66 -0
- package/dist/chunk-76PFOSM2.mjs +0 -41
- package/dist/chunk-DITNP6PL.mjs +0 -106
- package/dist/chunk-JBLL7U3U.mjs +0 -64
- package/dist/chunk-LG4DO3DK.mjs +0 -174
- package/dist/chunk-RMMK64W5.mjs +0 -54
- package/dist/chunk-RTC3CFXF.mjs +0 -29
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, ScrollView } from 'react-native'
|
|
3
|
+
import Animated from 'react-native-reanimated'
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
import { renderIcon } from '../../utils/icons'
|
|
6
|
+
import { usePressScale } from '../../utils/usePressScale'
|
|
7
|
+
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
8
|
+
import { PRESS_SCALE } from '../../utils/animations'
|
|
9
|
+
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
10
|
+
import { RADIUS } from '../../tokens'
|
|
11
|
+
|
|
12
|
+
export interface SelectableGridItem<T extends string | number = string> {
|
|
13
|
+
/** Unique value emitted on selection. */
|
|
14
|
+
value: T
|
|
15
|
+
/** Label rendered under the icon. */
|
|
16
|
+
label?: string
|
|
17
|
+
/** Icon name resolved via the icon registry. */
|
|
18
|
+
iconName?: string
|
|
19
|
+
/** Custom icon node — overrides `iconName`. */
|
|
20
|
+
icon?: React.ReactNode
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SelectableGridProps<T extends string | number = string> {
|
|
25
|
+
items: SelectableGridItem<T>[]
|
|
26
|
+
/** Selected value(s). Array when `multiple`. */
|
|
27
|
+
value: T | T[] | null
|
|
28
|
+
onChange: (value: T) => void
|
|
29
|
+
/** Allow multiple selections. `value` should be an array. Defaults to false. */
|
|
30
|
+
multiple?: boolean
|
|
31
|
+
/** Columns per row. Defaults to 4. Ignored when `orientation='horizontal'`. */
|
|
32
|
+
numColumns?: number
|
|
33
|
+
/** Gap between cells (dp). Defaults to 12. */
|
|
34
|
+
gap?: number
|
|
35
|
+
/** Layout orientation. 'grid' (default) wraps into rows. 'horizontal' creates a single scrollable row. */
|
|
36
|
+
orientation?: 'grid' | 'horizontal'
|
|
37
|
+
style?: ViewStyle
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isSelected<T extends string | number>(value: T | T[] | null, candidate: T): boolean {
|
|
41
|
+
if (value == null) return false
|
|
42
|
+
return Array.isArray(value) ? value.includes(candidate) : value === candidate
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CellProps<T extends string | number> {
|
|
46
|
+
item: SelectableGridItem<T>
|
|
47
|
+
selected: boolean
|
|
48
|
+
width: number
|
|
49
|
+
onPress: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Cell<T extends string | number>({ item, selected, width, onPress }: CellProps<T>) {
|
|
53
|
+
const { colors } = useTheme()
|
|
54
|
+
const { animatedStyle, onPressIn, onPressOut, hoverHandlers } = usePressScale({
|
|
55
|
+
pressScale: PRESS_SCALE.chip,
|
|
56
|
+
disabled: item.disabled,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const iconColor = selected ? colors.primary : colors.foregroundSubtle
|
|
60
|
+
const iconNode = item.icon ?? (item.iconName ? renderIcon(item.iconName, ms(24), iconColor) : null)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Animated.View style={[{ width }, animatedStyle]}>
|
|
64
|
+
<TouchableOpacity
|
|
65
|
+
onPress={onPress}
|
|
66
|
+
onPressIn={onPressIn}
|
|
67
|
+
onPressOut={onPressOut}
|
|
68
|
+
disabled={item.disabled}
|
|
69
|
+
activeOpacity={1}
|
|
70
|
+
touchSoundDisabled={true}
|
|
71
|
+
accessibilityRole="button"
|
|
72
|
+
accessibilityState={{ selected, disabled: item.disabled }}
|
|
73
|
+
accessibilityLabel={item.label ?? String(item.value)}
|
|
74
|
+
{...hoverHandlers}
|
|
75
|
+
style={[
|
|
76
|
+
styles.cell,
|
|
77
|
+
{
|
|
78
|
+
backgroundColor: selected ? colors.primary + '14' : colors.surface,
|
|
79
|
+
borderColor: selected ? colors.primary : 'transparent',
|
|
80
|
+
},
|
|
81
|
+
item.disabled && styles.cellDisabled,
|
|
82
|
+
]}
|
|
83
|
+
>
|
|
84
|
+
{iconNode}
|
|
85
|
+
{item.label ? (
|
|
86
|
+
<Text
|
|
87
|
+
style={[styles.label, { color: selected ? colors.primary : colors.foreground }]}
|
|
88
|
+
numberOfLines={1}
|
|
89
|
+
allowFontScaling={true}
|
|
90
|
+
>
|
|
91
|
+
{item.label}
|
|
92
|
+
</Text>
|
|
93
|
+
) : null}
|
|
94
|
+
</TouchableOpacity>
|
|
95
|
+
</Animated.View>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Grid of selectable cells (icon + label) — for store / category / emoji pickers
|
|
101
|
+
* where a list would be the wrong shape. Single or multi select.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* <SelectableGrid
|
|
105
|
+
* items={categories}
|
|
106
|
+
* value={selected}
|
|
107
|
+
* onChange={setSelected}
|
|
108
|
+
* numColumns={4}
|
|
109
|
+
* />
|
|
110
|
+
*/
|
|
111
|
+
export function SelectableGrid<T extends string | number = string>({
|
|
112
|
+
items,
|
|
113
|
+
value,
|
|
114
|
+
onChange,
|
|
115
|
+
multiple = false,
|
|
116
|
+
numColumns = 4,
|
|
117
|
+
gap = 12,
|
|
118
|
+
orientation = 'grid',
|
|
119
|
+
style,
|
|
120
|
+
}: SelectableGridProps<T>) {
|
|
121
|
+
const [containerWidth, setContainerWidth] = useState(0)
|
|
122
|
+
const gapPx = s(gap)
|
|
123
|
+
// Compute exact cell width so `numColumns` always fits — percentage widths + gap
|
|
124
|
+
// overflow and wrap one short. -0.5 guards against sub-pixel rounding overflow.
|
|
125
|
+
const cellWidth = containerWidth > 0 ? (containerWidth - gapPx * (numColumns - 1)) / numColumns - 0.5 : 0
|
|
126
|
+
// Horizontal mode: fixed 72dp cell width (same scale as grid cells)
|
|
127
|
+
const horizCellWidth = s(72)
|
|
128
|
+
|
|
129
|
+
const handlePress = (item: SelectableGridItem<T>) => {
|
|
130
|
+
if (item.disabled) return
|
|
131
|
+
hapticSelection()
|
|
132
|
+
onChange(item.value)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (orientation === 'horizontal') {
|
|
136
|
+
return (
|
|
137
|
+
<ScrollView
|
|
138
|
+
horizontal
|
|
139
|
+
showsHorizontalScrollIndicator={false}
|
|
140
|
+
contentContainerStyle={[styles.horizontal, { gap: gapPx }, style]}
|
|
141
|
+
accessibilityRole={multiple ? undefined : 'radiogroup'}
|
|
142
|
+
>
|
|
143
|
+
{items.map((item) => (
|
|
144
|
+
<Cell
|
|
145
|
+
key={String(item.value)}
|
|
146
|
+
item={item}
|
|
147
|
+
selected={isSelected(value, item.value)}
|
|
148
|
+
width={horizCellWidth}
|
|
149
|
+
onPress={() => handlePress(item)}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</ScrollView>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<View
|
|
158
|
+
style={[styles.grid, { gap: gapPx }, style]}
|
|
159
|
+
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
|
|
160
|
+
accessibilityRole={multiple ? undefined : 'radiogroup'}
|
|
161
|
+
>
|
|
162
|
+
{cellWidth > 0
|
|
163
|
+
? items.map((item) => (
|
|
164
|
+
<Cell
|
|
165
|
+
key={String(item.value)}
|
|
166
|
+
item={item}
|
|
167
|
+
selected={isSelected(value, item.value)}
|
|
168
|
+
width={cellWidth}
|
|
169
|
+
onPress={() => handlePress(item)}
|
|
170
|
+
/>
|
|
171
|
+
))
|
|
172
|
+
: null}
|
|
173
|
+
</View>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const styles = StyleSheet.create({
|
|
178
|
+
grid: {
|
|
179
|
+
flexDirection: 'row',
|
|
180
|
+
flexWrap: 'wrap',
|
|
181
|
+
},
|
|
182
|
+
horizontal: {
|
|
183
|
+
flexDirection: 'row',
|
|
184
|
+
paddingHorizontal: s(4),
|
|
185
|
+
},
|
|
186
|
+
cell: {
|
|
187
|
+
flex: 1,
|
|
188
|
+
borderRadius: RADIUS.md,
|
|
189
|
+
borderWidth: 2,
|
|
190
|
+
alignItems: 'center',
|
|
191
|
+
justifyContent: 'center',
|
|
192
|
+
gap: vs(4),
|
|
193
|
+
paddingHorizontal: s(12),
|
|
194
|
+
paddingVertical: vs(12),
|
|
195
|
+
},
|
|
196
|
+
cellDisabled: {
|
|
197
|
+
opacity: 0.4,
|
|
198
|
+
},
|
|
199
|
+
label: {
|
|
200
|
+
fontFamily: 'Sohne-Medium',
|
|
201
|
+
fontSize: ms(12),
|
|
202
|
+
lineHeight: mvs(15),
|
|
203
|
+
textAlign: 'center',
|
|
204
|
+
},
|
|
205
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './SelectableGrid'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef } from 'react'
|
|
2
|
-
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions, Platform } from 'react-native'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions, Platform, Modal, ScrollView, useWindowDimensions, Pressable } from 'react-native'
|
|
3
3
|
import {
|
|
4
4
|
BottomSheetModal,
|
|
5
5
|
BottomSheetView,
|
|
@@ -16,6 +16,7 @@ import { AntDesign } from '@expo/vector-icons'
|
|
|
16
16
|
import { impactMedium } from '../../utils/haptics'
|
|
17
17
|
import { useTheme } from '../../theme'
|
|
18
18
|
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
19
|
+
import { BREAKPOINTS, RADIUS, SHADOWS } from '../../tokens'
|
|
19
20
|
|
|
20
21
|
const SCREEN_HEIGHT = Dimensions.get('window').height
|
|
21
22
|
const DEFAULT_MAX_HEIGHT = SCREEN_HEIGHT * 0.85
|
|
@@ -93,6 +94,18 @@ export interface SheetProps {
|
|
|
93
94
|
* When omitted, sheet uses dynamic sizing (auto-fits content).
|
|
94
95
|
*/
|
|
95
96
|
snapPoints?: (string | number)[]
|
|
97
|
+
/**
|
|
98
|
+
* When true, render as a centered modal dialog on wide screens (width ≥
|
|
99
|
+
* `BREAKPOINTS.wide`) instead of a bottom sheet. On narrow screens it stays a
|
|
100
|
+
* bottom sheet. Use for store/category/picker dialogs that should feel native
|
|
101
|
+
* on tablets and web.
|
|
102
|
+
*
|
|
103
|
+
* Note: the centered-dialog path uses a plain RN `Modal`, so `SheetTextInput`
|
|
104
|
+
* is not required there — use a regular `TextInput`.
|
|
105
|
+
*/
|
|
106
|
+
responsive?: boolean
|
|
107
|
+
/** Max width of the centered dialog (dp). Only applies when `responsive`. Defaults to 480. */
|
|
108
|
+
dialogMaxWidth?: number
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
export function SheetHeader({ children, style }: SheetHeaderProps) {
|
|
@@ -130,10 +143,14 @@ export function Sheet({
|
|
|
130
143
|
android_keyboardInputMode = 'adjustPan',
|
|
131
144
|
footer,
|
|
132
145
|
snapPoints,
|
|
146
|
+
responsive = false,
|
|
147
|
+
dialogMaxWidth = 480,
|
|
133
148
|
}: SheetProps) {
|
|
134
149
|
const { colors } = useTheme()
|
|
135
150
|
const insets = useSafeAreaInsets()
|
|
151
|
+
const { width: windowWidth } = useWindowDimensions()
|
|
136
152
|
const ref = useRef<BottomSheetModal>(null)
|
|
153
|
+
const asDialog = responsive && windowWidth >= BREAKPOINTS.wide
|
|
137
154
|
|
|
138
155
|
// 'interactive' + 'adjustPan' works properly with enableDynamicSizing on both platforms
|
|
139
156
|
// 'fillParent' + 'adjustResize' causes restore issues (transparent gap when keyboard dismisses)
|
|
@@ -217,6 +234,35 @@ export function Sheet({
|
|
|
217
234
|
)
|
|
218
235
|
}, [effectiveFooter])
|
|
219
236
|
|
|
237
|
+
// Centered dialog path for wide screens — plain RN Modal, same header/content/footer.
|
|
238
|
+
if (asDialog) {
|
|
239
|
+
return (
|
|
240
|
+
<Modal visible={open} transparent animationType="fade" onRequestClose={onClose}>
|
|
241
|
+
<Pressable style={styles.dialogBackdrop} onPress={onClose} accessibilityRole="button" accessibilityLabel="Close">
|
|
242
|
+
{/* Inner Pressable swallows presses so taps inside the card don't close it. */}
|
|
243
|
+
<Pressable
|
|
244
|
+
style={[
|
|
245
|
+
styles.dialogCard,
|
|
246
|
+
{ backgroundColor: colors.card, maxWidth: dialogMaxWidth, maxHeight: SCREEN_HEIGHT * 0.85 },
|
|
247
|
+
]}
|
|
248
|
+
onPress={() => {}}
|
|
249
|
+
>
|
|
250
|
+
{headerNode}
|
|
251
|
+
<ScrollView
|
|
252
|
+
contentContainerStyle={[styles.dialogContent, style]}
|
|
253
|
+
style={contentStyle}
|
|
254
|
+
showsVerticalScrollIndicator={true}
|
|
255
|
+
bounces={false}
|
|
256
|
+
>
|
|
257
|
+
{contentNode}
|
|
258
|
+
</ScrollView>
|
|
259
|
+
{effectiveFooter}
|
|
260
|
+
</Pressable>
|
|
261
|
+
</Pressable>
|
|
262
|
+
</Modal>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
220
266
|
const useScroll = scrollable || !!maxHeight
|
|
221
267
|
const effectiveMaxHeight = maxHeight ?? DEFAULT_MAX_HEIGHT
|
|
222
268
|
|
|
@@ -323,5 +369,23 @@ const styles = StyleSheet.create({
|
|
|
323
369
|
flexDirection: 'row',
|
|
324
370
|
gap: s(12),
|
|
325
371
|
},
|
|
372
|
+
dialogBackdrop: {
|
|
373
|
+
flex: 1,
|
|
374
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
375
|
+
alignItems: 'center',
|
|
376
|
+
justifyContent: 'center',
|
|
377
|
+
padding: s(24),
|
|
378
|
+
},
|
|
379
|
+
dialogCard: {
|
|
380
|
+
width: '100%',
|
|
381
|
+
borderRadius: RADIUS.lg,
|
|
382
|
+
paddingTop: vs(16),
|
|
383
|
+
overflow: 'hidden',
|
|
384
|
+
...SHADOWS.xl,
|
|
385
|
+
},
|
|
386
|
+
dialogContent: {
|
|
387
|
+
paddingHorizontal: s(16),
|
|
388
|
+
paddingBottom: vs(16),
|
|
389
|
+
},
|
|
326
390
|
})
|
|
327
391
|
|
|
@@ -9,8 +9,9 @@ import Animated, {
|
|
|
9
9
|
} from 'react-native-reanimated'
|
|
10
10
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
11
11
|
import { useTheme } from '../../theme'
|
|
12
|
-
import { s } from '../../utils/scaling'
|
|
12
|
+
import { s, vs } from '../../utils/scaling'
|
|
13
13
|
import { TIMINGS } from '../../utils/animations'
|
|
14
|
+
import { RADIUS } from '../../tokens'
|
|
14
15
|
|
|
15
16
|
// circle: circular avatar placeholder text: short line preset base: custom dimensions
|
|
16
17
|
export type SkeletonPreset = 'base' | 'circle' | 'text'
|
|
@@ -94,8 +95,148 @@ export function Skeleton({
|
|
|
94
95
|
)
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// ─── Per-component skeletons ───────────────────────────────────────────────────
|
|
99
|
+
// Loading placeholders that mirror a component's footprint, so grids/lists don't
|
|
100
|
+
// reflow when real data arrives.
|
|
101
|
+
|
|
102
|
+
const aspectRatioMap = {
|
|
103
|
+
'1:1': 1,
|
|
104
|
+
'4:3': 3 / 4,
|
|
105
|
+
'16:9': 9 / 16,
|
|
106
|
+
'4:5': 5 / 4,
|
|
107
|
+
'3:2': 2 / 3,
|
|
108
|
+
} as const
|
|
109
|
+
|
|
110
|
+
export type MediaCardSkeletonAspectRatio = keyof typeof aspectRatioMap
|
|
111
|
+
|
|
112
|
+
export interface MediaCardSkeletonProps {
|
|
113
|
+
/** Image aspect ratio — match your `MediaCard`. Defaults to `'4:3'`. */
|
|
114
|
+
aspectRatio?: MediaCardSkeletonAspectRatio
|
|
115
|
+
/** Show the subtitle/caption line below the title. Defaults to true. */
|
|
116
|
+
showSubtitle?: boolean
|
|
117
|
+
style?: ViewStyle
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Loading placeholder matching `<MediaCard>` — image block + title/subtitle lines. */
|
|
121
|
+
export function MediaCardSkeleton({ aspectRatio = '4:3', showSubtitle = true, style }: MediaCardSkeletonProps) {
|
|
122
|
+
const ratio = aspectRatioMap[aspectRatio]
|
|
123
|
+
return (
|
|
124
|
+
<View style={style}>
|
|
125
|
+
<View style={{ paddingTop: `${ratio * 100}%` as `${number}%` }}>
|
|
126
|
+
<View style={StyleSheet.absoluteFill}>
|
|
127
|
+
<Skeleton width="100%" height={undefined as unknown as number} style={skeletonStyles.fill} borderRadius={RADIUS.md} />
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
<View style={skeletonStyles.meta}>
|
|
131
|
+
<Skeleton width="70%" height={vs(14)} borderRadius={RADIUS.xs} />
|
|
132
|
+
{showSubtitle ? <Skeleton width="45%" height={vs(12)} borderRadius={RADIUS.xs} /> : null}
|
|
133
|
+
</View>
|
|
134
|
+
</View>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ListItemSkeletonProps {
|
|
139
|
+
/** Render a circular leading avatar placeholder. Defaults to true. */
|
|
140
|
+
showAvatar?: boolean
|
|
141
|
+
/** Render a secondary subtitle line. Defaults to true. */
|
|
142
|
+
showSubtitle?: boolean
|
|
143
|
+
style?: ViewStyle
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Loading placeholder matching `<ListItem>` — leading circle + title/subtitle lines. */
|
|
147
|
+
export function ListItemSkeleton({ showAvatar = true, showSubtitle = true, style }: ListItemSkeletonProps) {
|
|
148
|
+
return (
|
|
149
|
+
<View style={[skeletonStyles.row, style]}>
|
|
150
|
+
{showAvatar ? <Skeleton preset="circle" diameter={40} /> : null}
|
|
151
|
+
<View style={skeletonStyles.rowText}>
|
|
152
|
+
<Skeleton width="60%" height={vs(14)} borderRadius={RADIUS.xs} />
|
|
153
|
+
{showSubtitle ? <Skeleton width="40%" height={vs(12)} borderRadius={RADIUS.xs} /> : null}
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ListSkeletonProps {
|
|
160
|
+
/** Number of placeholder rows/cells. Defaults to 6. */
|
|
161
|
+
count?: number
|
|
162
|
+
/** 1 = stacked list of `ListItemSkeleton`; >1 = grid of `MediaCardSkeleton`. Defaults to 1. */
|
|
163
|
+
columns?: number
|
|
164
|
+
/** Gap between items (dp). Defaults to 12. */
|
|
165
|
+
gap?: number
|
|
166
|
+
/** Grid only — aspect ratio of each `MediaCardSkeleton`. Defaults to `'4:3'`. */
|
|
167
|
+
aspectRatio?: MediaCardSkeletonAspectRatio
|
|
168
|
+
/** List only — show the leading avatar circle. Defaults to true. */
|
|
169
|
+
showAvatar?: boolean
|
|
170
|
+
style?: ViewStyle
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Repeated loading placeholder for a `VirtualList` / list / grid. `columns={1}`
|
|
175
|
+
* renders stacked `ListItemSkeleton`s; `columns>1` renders a wrapping grid of
|
|
176
|
+
* `MediaCardSkeleton`s. Render this as the list's content while `data` is empty.
|
|
177
|
+
*/
|
|
178
|
+
export function ListSkeleton({
|
|
179
|
+
count = 6,
|
|
180
|
+
columns = 1,
|
|
181
|
+
gap = 12,
|
|
182
|
+
aspectRatio = '4:3',
|
|
183
|
+
showAvatar = true,
|
|
184
|
+
style,
|
|
185
|
+
}: ListSkeletonProps) {
|
|
186
|
+
if (columns <= 1) {
|
|
187
|
+
return (
|
|
188
|
+
<View style={[{ gap: vs(gap) }, style]}>
|
|
189
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
190
|
+
<ListItemSkeleton key={i} showAvatar={showAvatar} />
|
|
191
|
+
))}
|
|
192
|
+
</View>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const widthPct = `${100 / columns}%` as `${number}%`
|
|
196
|
+
// Gutter via per-cell padding + marginBottom (not container `gap`) so percentage
|
|
197
|
+
// widths sum to exactly 100% and never wrap one short.
|
|
198
|
+
return (
|
|
199
|
+
<View style={[skeletonStyles.grid, { marginHorizontal: -s(gap) / 2 }, style]}>
|
|
200
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
201
|
+
<View key={i} style={{ width: widthPct, paddingHorizontal: s(gap) / 2, marginBottom: vs(gap) }}>
|
|
202
|
+
<MediaCardSkeleton aspectRatio={aspectRatio} />
|
|
203
|
+
</View>
|
|
204
|
+
))}
|
|
205
|
+
</View>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Skeleton.MediaCard = MediaCardSkeleton
|
|
210
|
+
Skeleton.ListItem = ListItemSkeleton
|
|
211
|
+
Skeleton.List = ListSkeleton
|
|
212
|
+
|
|
97
213
|
const styles = StyleSheet.create({
|
|
98
214
|
base: {
|
|
99
215
|
overflow: 'hidden',
|
|
100
216
|
},
|
|
101
217
|
})
|
|
218
|
+
|
|
219
|
+
const skeletonStyles = StyleSheet.create({
|
|
220
|
+
grid: {
|
|
221
|
+
flexDirection: 'row',
|
|
222
|
+
flexWrap: 'wrap',
|
|
223
|
+
},
|
|
224
|
+
fill: {
|
|
225
|
+
width: '100%',
|
|
226
|
+
height: '100%',
|
|
227
|
+
},
|
|
228
|
+
meta: {
|
|
229
|
+
paddingTop: vs(8),
|
|
230
|
+
gap: vs(6),
|
|
231
|
+
},
|
|
232
|
+
row: {
|
|
233
|
+
flexDirection: 'row',
|
|
234
|
+
alignItems: 'center',
|
|
235
|
+
gap: s(12),
|
|
236
|
+
paddingVertical: vs(8),
|
|
237
|
+
},
|
|
238
|
+
rowText: {
|
|
239
|
+
flex: 1,
|
|
240
|
+
gap: vs(6),
|
|
241
|
+
},
|
|
242
|
+
})
|
|
@@ -25,10 +25,16 @@ const labelFontSize: Record<SpinnerSize, number> = {
|
|
|
25
25
|
|
|
26
26
|
export function Spinner({ size = 'md', color, label, ...props }: SpinnerProps) {
|
|
27
27
|
const { colors } = useTheme()
|
|
28
|
+
const a11yLabel = label || 'Loading'
|
|
28
29
|
|
|
29
30
|
if (label) {
|
|
30
31
|
return (
|
|
31
|
-
<View
|
|
32
|
+
<View
|
|
33
|
+
style={styles.wrapper}
|
|
34
|
+
accessibilityRole="progressbar"
|
|
35
|
+
accessibilityLabel={a11yLabel}
|
|
36
|
+
accessibilityState={{ busy: true }}
|
|
37
|
+
>
|
|
32
38
|
<ActivityIndicator size={sizeMap[size]} color={color ?? colors.primary} {...props} />
|
|
33
39
|
<Text
|
|
34
40
|
style={[styles.label, { color: colors.foregroundMuted, fontSize: labelFontSize[size] }]}
|
|
@@ -40,7 +46,16 @@ export function Spinner({ size = 'md', color, label, ...props }: SpinnerProps) {
|
|
|
40
46
|
)
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
return
|
|
49
|
+
return (
|
|
50
|
+
<ActivityIndicator
|
|
51
|
+
size={sizeMap[size]}
|
|
52
|
+
color={color ?? colors.primary}
|
|
53
|
+
accessibilityRole="progressbar"
|
|
54
|
+
accessibilityLabel={a11yLabel}
|
|
55
|
+
accessibilityState={{ busy: true }}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
const styles = StyleSheet.create({
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react'
|
|
2
2
|
import { TouchableOpacity, StyleSheet, ViewStyle, View } from 'react-native'
|
|
3
|
-
import
|
|
4
|
-
useSharedValue,
|
|
5
|
-
useAnimatedStyle,
|
|
6
|
-
withSpring,
|
|
7
|
-
withTiming,
|
|
8
|
-
interpolateColor,
|
|
9
|
-
} from 'react-native-reanimated'
|
|
3
|
+
import { EaseView } from 'react-native-ease'
|
|
10
4
|
import { Feather } from '@expo/vector-icons'
|
|
11
5
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
12
6
|
import { useTheme } from '../../theme'
|
|
13
7
|
import { s } from '../../utils/scaling'
|
|
14
|
-
import {
|
|
8
|
+
import { COLOR_TRANSITION, OPACITY_TRANSITION, SPRING_ELASTIC } from '../../utils/animations'
|
|
15
9
|
|
|
16
10
|
const TRACK_WIDTH = s(52)
|
|
17
11
|
const TRACK_HEIGHT = s(30)
|
|
@@ -31,45 +25,6 @@ export interface SwitchProps {
|
|
|
31
25
|
export function Switch({ checked = false, onCheckedChange, disabled, style, accessibilityLabel }: SwitchProps) {
|
|
32
26
|
const { colors } = useTheme()
|
|
33
27
|
|
|
34
|
-
const progress = useSharedValue(checked ? 1 : 0)
|
|
35
|
-
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
progress.value = withSpring(checked ? 1 : 0, SPRINGS.elastic)
|
|
38
|
-
}, [checked, progress])
|
|
39
|
-
|
|
40
|
-
const thumbStyle = useAnimatedStyle(() => ({
|
|
41
|
-
transform: [{ translateX: progress.value * THUMB_TRAVEL }],
|
|
42
|
-
}))
|
|
43
|
-
|
|
44
|
-
const trackStyle = useAnimatedStyle(() => ({
|
|
45
|
-
backgroundColor: interpolateColor(
|
|
46
|
-
progress.value,
|
|
47
|
-
[0, 1],
|
|
48
|
-
[colors.surfaceStrong, colors.primary],
|
|
49
|
-
),
|
|
50
|
-
}))
|
|
51
|
-
|
|
52
|
-
// AUDIT FIX: the off-state track used surfaceStrong (~#ebebeb in light mode)
|
|
53
|
-
// with no border — nearly invisible on white page/card surfaces. A 1.5px border
|
|
54
|
-
// that fades out as the track fills gives the off state clear visual definition
|
|
55
|
-
// without adding visual weight to the on state.
|
|
56
|
-
const trackBorderStyle = useAnimatedStyle(() => ({
|
|
57
|
-
borderWidth: 1.5,
|
|
58
|
-
borderColor: interpolateColor(
|
|
59
|
-
progress.value,
|
|
60
|
-
[0, 1],
|
|
61
|
-
[colors.border, 'transparent'],
|
|
62
|
-
),
|
|
63
|
-
}))
|
|
64
|
-
|
|
65
|
-
const checkIconStyle = useAnimatedStyle(() => ({
|
|
66
|
-
opacity: withTiming(checked ? 1 : 0, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
|
|
67
|
-
}))
|
|
68
|
-
|
|
69
|
-
const crossIconStyle = useAnimatedStyle(() => ({
|
|
70
|
-
opacity: withTiming(checked ? 0 : 1, { duration: TIMINGS.state.duration, easing: EASINGS.standard }),
|
|
71
|
-
}))
|
|
72
|
-
|
|
73
28
|
return (
|
|
74
29
|
<View style={[{ opacity: disabled ? 0.45 : 1, alignSelf: 'flex-start' }, style]}>
|
|
75
30
|
<TouchableOpacity
|
|
@@ -85,19 +40,36 @@ export function Switch({ checked = false, onCheckedChange, disabled, style, acce
|
|
|
85
40
|
accessibilityState={{ checked, disabled: !!disabled }}
|
|
86
41
|
style={styles.touchable}
|
|
87
42
|
>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
43
|
+
<EaseView
|
|
44
|
+
style={styles.track}
|
|
45
|
+
animate={{ backgroundColor: checked ? colors.primary : colors.surfaceStrong }}
|
|
46
|
+
transition={COLOR_TRANSITION}
|
|
47
|
+
>
|
|
48
|
+
{/*
|
|
49
|
+
AUDIT FIX: the off-state track used surfaceStrong (~#ebebeb in light mode)
|
|
50
|
+
with no border — nearly invisible on white page/card surfaces. A 1.5px border
|
|
51
|
+
that fades out as the track fills gives the off state clear visual definition
|
|
52
|
+
without adding visual weight to the on state.
|
|
53
|
+
*/}
|
|
54
|
+
<EaseView
|
|
55
|
+
style={[styles.trackBorder, { borderWidth: 1.5 }]}
|
|
56
|
+
pointerEvents="none"
|
|
57
|
+
animate={{ borderColor: checked ? 'transparent' : colors.border }}
|
|
58
|
+
transition={COLOR_TRANSITION}
|
|
59
|
+
/>
|
|
60
|
+
<EaseView
|
|
61
|
+
style={[styles.thumb, { backgroundColor: colors.primaryForeground }]}
|
|
62
|
+
animate={{ translateX: checked ? THUMB_TRAVEL : 0 }}
|
|
63
|
+
transition={SPRING_ELASTIC}
|
|
92
64
|
>
|
|
93
|
-
<
|
|
65
|
+
<EaseView style={styles.iconWrapper} animate={{ opacity: checked ? 1 : 0 }} transition={OPACITY_TRANSITION}>
|
|
94
66
|
<Feather name="check" size={ICON_SIZE} color={colors.primary} />
|
|
95
|
-
</
|
|
96
|
-
<
|
|
67
|
+
</EaseView>
|
|
68
|
+
<EaseView style={styles.iconWrapper} animate={{ opacity: checked ? 0 : 1 }} transition={OPACITY_TRANSITION}>
|
|
97
69
|
<Feather name="x" size={ICON_SIZE} color={colors.foregroundMuted} />
|
|
98
|
-
</
|
|
99
|
-
</
|
|
100
|
-
</
|
|
70
|
+
</EaseView>
|
|
71
|
+
</EaseView>
|
|
72
|
+
</EaseView>
|
|
101
73
|
</TouchableOpacity>
|
|
102
74
|
</View>
|
|
103
75
|
)
|