@obisoft/ui 0.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/README.md +45 -0
- package/package.json +52 -0
- package/src/components/AppHeader.tsx +67 -0
- package/src/components/BottomSheet.tsx +110 -0
- package/src/components/Button.tsx +117 -0
- package/src/components/Card.tsx +46 -0
- package/src/components/CenteredState.tsx +35 -0
- package/src/components/Chip.tsx +47 -0
- package/src/components/ChoiceField.tsx +44 -0
- package/src/components/DateRangeFilter.tsx +226 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/ErrorState.tsx +62 -0
- package/src/components/Fab.tsx +36 -0
- package/src/components/FormRow.tsx +39 -0
- package/src/components/FormWorkspaceShell.tsx +126 -0
- package/src/components/GlassTabBar.tsx +39 -0
- package/src/components/Icon.tsx +259 -0
- package/src/components/IconButton.tsx +73 -0
- package/src/components/ListFilterBar.tsx +134 -0
- package/src/components/ListRow.tsx +198 -0
- package/src/components/ListWorkspace.tsx +76 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/MetricCard.tsx +74 -0
- package/src/components/MetricsGrid.tsx +25 -0
- package/src/components/MiniBarChart.tsx +42 -0
- package/src/components/NavigationDrawer.tsx +243 -0
- package/src/components/QueryState.tsx +82 -0
- package/src/components/QueryWorkspace.tsx +74 -0
- package/src/components/ScanInputBar.tsx +69 -0
- package/src/components/ScannerShell.tsx +76 -0
- package/src/components/Screen.tsx +44 -0
- package/src/components/SearchArea.tsx +34 -0
- package/src/components/SegmentedControl.tsx +64 -0
- package/src/components/Skeleton.tsx +42 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/SuccessState.tsx +41 -0
- package/src/components/TextField.tsx +47 -0
- package/src/components/ToggleRow.tsx +35 -0
- package/src/components/record-detail/RecordDetail.tsx +60 -0
- package/src/components/record-detail/RecordDetailActions.tsx +88 -0
- package/src/components/record-detail/RecordDetailField.tsx +43 -0
- package/src/components/record-detail/RecordDetailHero.tsx +46 -0
- package/src/components/record-detail/RecordDetailLine.tsx +36 -0
- package/src/components/record-detail/RecordDetailSection.tsx +36 -0
- package/src/components/record-detail/index.ts +7 -0
- package/src/components/record-detail/status.ts +16 -0
- package/src/gallery/catalog.ts +31 -0
- package/src/glass/AmbientBackground.tsx +29 -0
- package/src/glass/GlassSurface.tsx +107 -0
- package/src/index.ts +66 -0
- package/src/layout/Box.tsx +62 -0
- package/src/layout/Divider.tsx +8 -0
- package/src/layout/Row.tsx +7 -0
- package/src/layout/Stack.tsx +7 -0
- package/src/provider/ObiUIProvider.tsx +45 -0
- package/src/styles/useObiStyles.ts +288 -0
- package/src/theme/effects.ts +219 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/theme.ts +53 -0
- package/src/theme/tokens.ts +133 -0
- package/src/typography/ObiText.tsx +52 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ArrowLeft,
|
|
4
|
+
ArrowRight,
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
Ban,
|
|
7
|
+
Banknote,
|
|
8
|
+
Bell,
|
|
9
|
+
Bluetooth,
|
|
10
|
+
Bookmark,
|
|
11
|
+
BookText,
|
|
12
|
+
Calculator,
|
|
13
|
+
CalendarDays,
|
|
14
|
+
Car,
|
|
15
|
+
ChartBar,
|
|
16
|
+
Check,
|
|
17
|
+
ChevronDown,
|
|
18
|
+
ChevronRight,
|
|
19
|
+
ChevronUp,
|
|
20
|
+
CircleCheckBig,
|
|
21
|
+
ClipboardList,
|
|
22
|
+
Clock,
|
|
23
|
+
Contact,
|
|
24
|
+
CreditCard,
|
|
25
|
+
Dot,
|
|
26
|
+
Dumbbell,
|
|
27
|
+
Eye,
|
|
28
|
+
Factory,
|
|
29
|
+
Files,
|
|
30
|
+
FilePenLine,
|
|
31
|
+
FileText,
|
|
32
|
+
FolderKanban,
|
|
33
|
+
Gift,
|
|
34
|
+
HandCoins,
|
|
35
|
+
House,
|
|
36
|
+
KeyRound,
|
|
37
|
+
Landmark,
|
|
38
|
+
LogOut,
|
|
39
|
+
Mail,
|
|
40
|
+
Map,
|
|
41
|
+
MapPin,
|
|
42
|
+
Menu,
|
|
43
|
+
Minus,
|
|
44
|
+
MessageCircle,
|
|
45
|
+
Moon,
|
|
46
|
+
Package,
|
|
47
|
+
Pencil,
|
|
48
|
+
Pill,
|
|
49
|
+
Pin,
|
|
50
|
+
Plus,
|
|
51
|
+
Power,
|
|
52
|
+
Printer,
|
|
53
|
+
Receipt,
|
|
54
|
+
RefreshCw,
|
|
55
|
+
Scale,
|
|
56
|
+
Search,
|
|
57
|
+
ScanLine,
|
|
58
|
+
Send,
|
|
59
|
+
Settings,
|
|
60
|
+
Share2,
|
|
61
|
+
Shield,
|
|
62
|
+
Shirt,
|
|
63
|
+
ShoppingCart,
|
|
64
|
+
Sparkle,
|
|
65
|
+
Sparkles,
|
|
66
|
+
Star,
|
|
67
|
+
Sun,
|
|
68
|
+
Tag,
|
|
69
|
+
TrendingUp,
|
|
70
|
+
Trash2,
|
|
71
|
+
Truck,
|
|
72
|
+
UtensilsCrossed,
|
|
73
|
+
User,
|
|
74
|
+
Users,
|
|
75
|
+
Undo2,
|
|
76
|
+
Upload,
|
|
77
|
+
Wallet,
|
|
78
|
+
Wrench,
|
|
79
|
+
X,
|
|
80
|
+
Zap,
|
|
81
|
+
type LucideIcon,
|
|
82
|
+
} from 'lucide-react-native';
|
|
83
|
+
|
|
84
|
+
export type IconName =
|
|
85
|
+
| 'document'
|
|
86
|
+
| 'share'
|
|
87
|
+
| 'print'
|
|
88
|
+
| 'mail'
|
|
89
|
+
| 'bluetooth'
|
|
90
|
+
| 'collect'
|
|
91
|
+
| 'invoice'
|
|
92
|
+
| 'delivery'
|
|
93
|
+
| 'check'
|
|
94
|
+
| 'close'
|
|
95
|
+
| 'send'
|
|
96
|
+
| 'power'
|
|
97
|
+
| 'home'
|
|
98
|
+
| 'menu'
|
|
99
|
+
| 'arrow-left'
|
|
100
|
+
| 'arrow-right'
|
|
101
|
+
| 'chevron-down'
|
|
102
|
+
| 'chevron-right'
|
|
103
|
+
| 'chevron-up'
|
|
104
|
+
| 'dot'
|
|
105
|
+
| 'sun'
|
|
106
|
+
| 'moon'
|
|
107
|
+
| 'bell'
|
|
108
|
+
| 'log-out'
|
|
109
|
+
| 'settings'
|
|
110
|
+
| 'search'
|
|
111
|
+
| 'users'
|
|
112
|
+
| 'file-text'
|
|
113
|
+
| 'shield'
|
|
114
|
+
| 'trending-up'
|
|
115
|
+
| 'banknote'
|
|
116
|
+
| 'bar-chart'
|
|
117
|
+
| 'wallet'
|
|
118
|
+
| 'files'
|
|
119
|
+
| 'undo'
|
|
120
|
+
| 'arrow-up-right'
|
|
121
|
+
| 'upload'
|
|
122
|
+
| 'clipboard-list'
|
|
123
|
+
| 'truck'
|
|
124
|
+
| 'package'
|
|
125
|
+
| 'check-circle'
|
|
126
|
+
| 'refresh'
|
|
127
|
+
| 'shopping-cart'
|
|
128
|
+
| 'tag'
|
|
129
|
+
| 'bookmark'
|
|
130
|
+
| 'contact'
|
|
131
|
+
| 'map-pin'
|
|
132
|
+
| 'scale'
|
|
133
|
+
| 'calendar'
|
|
134
|
+
| 'factory'
|
|
135
|
+
| 'history'
|
|
136
|
+
| 'credit-card'
|
|
137
|
+
| 'book'
|
|
138
|
+
| 'calculator'
|
|
139
|
+
| 'map'
|
|
140
|
+
| 'star'
|
|
141
|
+
| 'message-circle'
|
|
142
|
+
| 'user'
|
|
143
|
+
| 'key'
|
|
144
|
+
| 'file-edit'
|
|
145
|
+
| 'pencil'
|
|
146
|
+
| 'ban'
|
|
147
|
+
| 'landmark'
|
|
148
|
+
| 'plus'
|
|
149
|
+
| 'minus'
|
|
150
|
+
| 'trash-2'
|
|
151
|
+
| 'scan-line'
|
|
152
|
+
| 'eye'
|
|
153
|
+
| 'folder-kanban'
|
|
154
|
+
| 'utensils'
|
|
155
|
+
| 'zap'
|
|
156
|
+
| 'shirt'
|
|
157
|
+
| 'wrench'
|
|
158
|
+
| 'pill'
|
|
159
|
+
| 'dumbbell'
|
|
160
|
+
| 'car'
|
|
161
|
+
| 'sparkles'
|
|
162
|
+
| 'sparkle'
|
|
163
|
+
| 'gift'
|
|
164
|
+
| 'pin';
|
|
165
|
+
|
|
166
|
+
const ICONS: Record<IconName, LucideIcon> = {
|
|
167
|
+
document: FileText,
|
|
168
|
+
share: Share2,
|
|
169
|
+
print: Printer,
|
|
170
|
+
mail: Mail,
|
|
171
|
+
bluetooth: Bluetooth,
|
|
172
|
+
collect: HandCoins,
|
|
173
|
+
invoice: Receipt,
|
|
174
|
+
delivery: Truck,
|
|
175
|
+
check: Check,
|
|
176
|
+
close: X,
|
|
177
|
+
send: Send,
|
|
178
|
+
power: Power,
|
|
179
|
+
home: House,
|
|
180
|
+
menu: Menu,
|
|
181
|
+
'arrow-left': ArrowLeft,
|
|
182
|
+
'arrow-right': ArrowRight,
|
|
183
|
+
'chevron-down': ChevronDown,
|
|
184
|
+
'chevron-right': ChevronRight,
|
|
185
|
+
'chevron-up': ChevronUp,
|
|
186
|
+
dot: Dot,
|
|
187
|
+
sun: Sun,
|
|
188
|
+
moon: Moon,
|
|
189
|
+
bell: Bell,
|
|
190
|
+
'log-out': LogOut,
|
|
191
|
+
settings: Settings,
|
|
192
|
+
search: Search,
|
|
193
|
+
users: Users,
|
|
194
|
+
'file-text': FileText,
|
|
195
|
+
shield: Shield,
|
|
196
|
+
'trending-up': TrendingUp,
|
|
197
|
+
banknote: Banknote,
|
|
198
|
+
'bar-chart': ChartBar,
|
|
199
|
+
wallet: Wallet,
|
|
200
|
+
files: Files,
|
|
201
|
+
undo: Undo2,
|
|
202
|
+
'arrow-up-right': ArrowUpRight,
|
|
203
|
+
upload: Upload,
|
|
204
|
+
'clipboard-list': ClipboardList,
|
|
205
|
+
truck: Truck,
|
|
206
|
+
package: Package,
|
|
207
|
+
'check-circle': CircleCheckBig,
|
|
208
|
+
refresh: RefreshCw,
|
|
209
|
+
'shopping-cart': ShoppingCart,
|
|
210
|
+
tag: Tag,
|
|
211
|
+
bookmark: Bookmark,
|
|
212
|
+
contact: Contact,
|
|
213
|
+
'map-pin': MapPin,
|
|
214
|
+
scale: Scale,
|
|
215
|
+
calendar: CalendarDays,
|
|
216
|
+
factory: Factory,
|
|
217
|
+
history: Clock,
|
|
218
|
+
'credit-card': CreditCard,
|
|
219
|
+
book: BookText,
|
|
220
|
+
calculator: Calculator,
|
|
221
|
+
map: Map,
|
|
222
|
+
star: Star,
|
|
223
|
+
'message-circle': MessageCircle,
|
|
224
|
+
user: User,
|
|
225
|
+
key: KeyRound,
|
|
226
|
+
'file-edit': FilePenLine,
|
|
227
|
+
pencil: Pencil,
|
|
228
|
+
ban: Ban,
|
|
229
|
+
landmark: Landmark,
|
|
230
|
+
plus: Plus,
|
|
231
|
+
minus: Minus,
|
|
232
|
+
'trash-2': Trash2,
|
|
233
|
+
'scan-line': ScanLine,
|
|
234
|
+
eye: Eye,
|
|
235
|
+
'folder-kanban': FolderKanban,
|
|
236
|
+
utensils: UtensilsCrossed,
|
|
237
|
+
zap: Zap,
|
|
238
|
+
shirt: Shirt,
|
|
239
|
+
wrench: Wrench,
|
|
240
|
+
pill: Pill,
|
|
241
|
+
dumbbell: Dumbbell,
|
|
242
|
+
car: Car,
|
|
243
|
+
sparkles: Sparkles,
|
|
244
|
+
sparkle: Sparkle,
|
|
245
|
+
gift: Gift,
|
|
246
|
+
pin: Pin,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export type IconProps = {
|
|
250
|
+
name: IconName;
|
|
251
|
+
size?: number;
|
|
252
|
+
color?: string;
|
|
253
|
+
strokeWidth?: number;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export function Icon({ name, size = 22, color = '#111827', strokeWidth = 2 }: IconProps) {
|
|
257
|
+
const Cmp = ICONS[name];
|
|
258
|
+
return <Cmp size={size} color={color} strokeWidth={strokeWidth} />;
|
|
259
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
import { shadow } from '../theme/effects';
|
|
5
|
+
import { Icon, type IconName } from './Icon';
|
|
6
|
+
|
|
7
|
+
type IconButtonVariant = 'ghost' | 'glass' | 'solid' | 'danger';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
icon: IconName;
|
|
11
|
+
onPress?: () => void;
|
|
12
|
+
accessibilityLabel: string;
|
|
13
|
+
size?: 'sm' | 'md' | 'lg';
|
|
14
|
+
variant?: IconButtonVariant;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
selected?: boolean;
|
|
17
|
+
color?: string;
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function IconButton({
|
|
22
|
+
icon,
|
|
23
|
+
onPress,
|
|
24
|
+
accessibilityLabel,
|
|
25
|
+
size = 'md',
|
|
26
|
+
variant = 'glass',
|
|
27
|
+
disabled,
|
|
28
|
+
selected,
|
|
29
|
+
color,
|
|
30
|
+
style,
|
|
31
|
+
}: Props) {
|
|
32
|
+
const theme = useObiTheme();
|
|
33
|
+
const box = size === 'sm' ? 38 : size === 'lg' ? 50 : 44;
|
|
34
|
+
const iconSize = size === 'sm' ? 18 : size === 'lg' ? 24 : 20;
|
|
35
|
+
const backgroundColor = selected
|
|
36
|
+
? theme.glass.primaryTintStrong
|
|
37
|
+
: variant === 'solid'
|
|
38
|
+
? theme.colors.primary
|
|
39
|
+
: variant === 'danger'
|
|
40
|
+
? theme.glass.dangerTint
|
|
41
|
+
: variant === 'glass'
|
|
42
|
+
? theme.glass.control
|
|
43
|
+
: 'transparent';
|
|
44
|
+
const foreground = color ?? (variant === 'solid' ? theme.colors.onPrimary : variant === 'danger' ? theme.colors.danger : theme.colors.textStrong);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Pressable
|
|
48
|
+
accessibilityRole="button"
|
|
49
|
+
accessibilityLabel={accessibilityLabel}
|
|
50
|
+
accessibilityState={{ disabled: Boolean(disabled), selected: Boolean(selected) }}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
onPress={onPress}
|
|
53
|
+
hitSlop={8}
|
|
54
|
+
style={({ pressed }) => [
|
|
55
|
+
{
|
|
56
|
+
width: box,
|
|
57
|
+
height: box,
|
|
58
|
+
borderRadius: theme.radius.md,
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
justifyContent: 'center',
|
|
61
|
+
backgroundColor,
|
|
62
|
+
borderWidth: variant === 'glass' || selected ? 1 : 0,
|
|
63
|
+
borderColor: selected ? theme.colors.primary : theme.glass.border,
|
|
64
|
+
opacity: disabled ? 0.4 : pressed ? 0.72 : 1,
|
|
65
|
+
...(variant === 'glass' ? shadow.glassSm : {}),
|
|
66
|
+
},
|
|
67
|
+
style,
|
|
68
|
+
]}
|
|
69
|
+
>
|
|
70
|
+
<Icon name={icon} size={iconSize} color={foreground} />
|
|
71
|
+
</Pressable>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
4
|
+
import { shadow } from '../theme/effects';
|
|
5
|
+
import { BottomSheet } from './BottomSheet';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { TextField } from './TextField';
|
|
8
|
+
|
|
9
|
+
export type FilterOption = { value: string; label: string };
|
|
10
|
+
|
|
11
|
+
export type ListFilterBarProps = {
|
|
12
|
+
search?: string;
|
|
13
|
+
onSearchChange?: (value: string) => void;
|
|
14
|
+
searchPlaceholder?: string;
|
|
15
|
+
filterValue?: string;
|
|
16
|
+
onFilterChange?: (value: string) => void;
|
|
17
|
+
filterOptions?: FilterOption[];
|
|
18
|
+
filterTitle?: string;
|
|
19
|
+
extra?: React.ReactNode;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function ListFilterBar({
|
|
23
|
+
search,
|
|
24
|
+
onSearchChange,
|
|
25
|
+
searchPlaceholder = 'Buscar…',
|
|
26
|
+
filterValue,
|
|
27
|
+
onFilterChange,
|
|
28
|
+
filterOptions,
|
|
29
|
+
filterTitle = 'Filtrar por estado',
|
|
30
|
+
extra,
|
|
31
|
+
}: ListFilterBarProps) {
|
|
32
|
+
const theme = useObiTheme();
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const hasSearch = onSearchChange != null;
|
|
35
|
+
const hasFilter = Boolean(filterOptions?.length && onFilterChange);
|
|
36
|
+
const isFilterActive = hasFilter && filterValue != null && filterValue !== filterOptions![0].value;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
40
|
+
<View style={{ flexDirection: 'row', gap: theme.spacing.sm, alignItems: 'center' }}>
|
|
41
|
+
{hasSearch ? (
|
|
42
|
+
<View style={{ flex: 1 }}>
|
|
43
|
+
<TextField
|
|
44
|
+
value={search}
|
|
45
|
+
onChangeText={onSearchChange}
|
|
46
|
+
placeholder={searchPlaceholder}
|
|
47
|
+
autoCapitalize="none"
|
|
48
|
+
/>
|
|
49
|
+
</View>
|
|
50
|
+
) : null}
|
|
51
|
+
{hasFilter ? (
|
|
52
|
+
<Pressable
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={filterTitle}
|
|
55
|
+
accessibilityState={{ expanded: open, selected: isFilterActive }}
|
|
56
|
+
onPress={() => setOpen(true)}
|
|
57
|
+
hitSlop={8}
|
|
58
|
+
style={({ pressed }) => ({
|
|
59
|
+
width: 48,
|
|
60
|
+
height: 48,
|
|
61
|
+
borderRadius: theme.radius.md,
|
|
62
|
+
borderWidth: 1,
|
|
63
|
+
borderColor: isFilterActive ? theme.colors.primary : theme.glass.border,
|
|
64
|
+
backgroundColor: isFilterActive ? theme.glass.primaryTintStrong : theme.glass.surface,
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
opacity: pressed ? 0.76 : 1,
|
|
68
|
+
...shadow.glassSm,
|
|
69
|
+
})}
|
|
70
|
+
>
|
|
71
|
+
<View style={{ gap: 3, alignItems: 'flex-start' }}>
|
|
72
|
+
{[16, 11, 6].map(width => (
|
|
73
|
+
<View
|
|
74
|
+
key={width}
|
|
75
|
+
style={{
|
|
76
|
+
width,
|
|
77
|
+
height: 2,
|
|
78
|
+
borderRadius: 1,
|
|
79
|
+
backgroundColor: isFilterActive ? theme.colors.primary : theme.colors.textMuted,
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</View>
|
|
84
|
+
</Pressable>
|
|
85
|
+
) : null}
|
|
86
|
+
</View>
|
|
87
|
+
{extra}
|
|
88
|
+
|
|
89
|
+
{hasFilter ? (
|
|
90
|
+
<BottomSheet
|
|
91
|
+
visible={open}
|
|
92
|
+
onClose={() => setOpen(false)}
|
|
93
|
+
title={filterTitle}
|
|
94
|
+
minHeight={320}
|
|
95
|
+
contentStyle={{ gap: theme.spacing.xs, paddingTop: theme.spacing.sm, paddingBottom: theme.spacing.lg, flexGrow: 1 }}
|
|
96
|
+
>
|
|
97
|
+
{filterOptions!.map(option => {
|
|
98
|
+
const selected = option.value === filterValue;
|
|
99
|
+
return (
|
|
100
|
+
<Pressable
|
|
101
|
+
key={option.value}
|
|
102
|
+
onPress={() => {
|
|
103
|
+
onFilterChange!(option.value);
|
|
104
|
+
setOpen(false);
|
|
105
|
+
}}
|
|
106
|
+
style={({ pressed }) => ({
|
|
107
|
+
minHeight: 56,
|
|
108
|
+
paddingVertical: theme.spacing.lg,
|
|
109
|
+
flexDirection: 'row',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
justifyContent: 'space-between',
|
|
112
|
+
borderBottomWidth: 1,
|
|
113
|
+
borderBottomColor: theme.glass.separator,
|
|
114
|
+
backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent',
|
|
115
|
+
})}
|
|
116
|
+
>
|
|
117
|
+
<Text
|
|
118
|
+
style={{
|
|
119
|
+
fontSize: theme.type.body,
|
|
120
|
+
color: selected ? theme.colors.primary : theme.colors.text,
|
|
121
|
+
fontWeight: selected ? '800' : '600',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{option.label}
|
|
125
|
+
</Text>
|
|
126
|
+
{selected ? <Icon name="check" size={16} color={theme.colors.primary} /> : null}
|
|
127
|
+
</Pressable>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</BottomSheet>
|
|
131
|
+
) : null}
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React, { useCallback, useRef } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import Animated, { type SharedValue, useAnimatedStyle } from 'react-native-reanimated';
|
|
4
|
+
import Swipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable';
|
|
5
|
+
import { useObiTheme } from '../provider/ObiUIProvider';
|
|
6
|
+
import { Icon, type IconName } from './Icon';
|
|
7
|
+
import { StatusBadge, type StatusTone } from './StatusBadge';
|
|
8
|
+
|
|
9
|
+
export type SwipeActionTone = 'primary' | 'danger' | 'success' | 'warning' | 'neutral';
|
|
10
|
+
|
|
11
|
+
export type SwipeAction = {
|
|
12
|
+
key: string;
|
|
13
|
+
icon: IconName;
|
|
14
|
+
label: string;
|
|
15
|
+
tone?: SwipeActionTone;
|
|
16
|
+
onPress: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ListRowProps = {
|
|
20
|
+
title: string;
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
captions?: (string | null | undefined | false)[];
|
|
23
|
+
amount?: string;
|
|
24
|
+
badgeLabel?: string;
|
|
25
|
+
badgeTone?: StatusTone;
|
|
26
|
+
onPress?: () => void;
|
|
27
|
+
footer?: React.ReactNode;
|
|
28
|
+
icon?: React.ReactNode;
|
|
29
|
+
swipeActions?: SwipeAction[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ACTION_WIDTH = 76;
|
|
33
|
+
const MAX_SWIPE_ACTIONS = 4;
|
|
34
|
+
|
|
35
|
+
let closeOpenSwipe: (() => void) | null = null;
|
|
36
|
+
|
|
37
|
+
function claimSwipeOpen(close: () => void) {
|
|
38
|
+
if (closeOpenSwipe && closeOpenSwipe !== close) closeOpenSwipe();
|
|
39
|
+
closeOpenSwipe = close;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function releaseSwipeOpen(close: () => void) {
|
|
43
|
+
if (closeOpenSwipe === close) closeOpenSwipe = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function SwipeActionsPanel({
|
|
47
|
+
actions,
|
|
48
|
+
progress,
|
|
49
|
+
methods,
|
|
50
|
+
}: {
|
|
51
|
+
actions: SwipeAction[];
|
|
52
|
+
progress: SharedValue<number>;
|
|
53
|
+
methods: SwipeableMethods;
|
|
54
|
+
}) {
|
|
55
|
+
const theme = useObiTheme();
|
|
56
|
+
const hideUntilSwipe = useAnimatedStyle(() => ({ opacity: progress.value <= 0.001 ? 0 : 1 }));
|
|
57
|
+
const toneColor = (tone: SwipeActionTone) => {
|
|
58
|
+
switch (tone) {
|
|
59
|
+
case 'primary': return theme.colors.primary;
|
|
60
|
+
case 'danger': return theme.colors.danger;
|
|
61
|
+
case 'success': return theme.colors.success;
|
|
62
|
+
case 'warning': return theme.colors.warning;
|
|
63
|
+
default: return theme.colors.textMuted;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Animated.View
|
|
69
|
+
style={[
|
|
70
|
+
{ flexDirection: 'row', alignItems: 'stretch', height: '100%', width: ACTION_WIDTH * actions.length },
|
|
71
|
+
hideUntilSwipe,
|
|
72
|
+
]}
|
|
73
|
+
>
|
|
74
|
+
{actions.map(action => {
|
|
75
|
+
const tone = action.tone ?? 'neutral';
|
|
76
|
+
return (
|
|
77
|
+
<Pressable
|
|
78
|
+
key={action.key}
|
|
79
|
+
accessibilityRole="button"
|
|
80
|
+
accessibilityLabel={action.label}
|
|
81
|
+
onPress={() => {
|
|
82
|
+
methods.close();
|
|
83
|
+
action.onPress();
|
|
84
|
+
}}
|
|
85
|
+
style={({ pressed }) => ({
|
|
86
|
+
width: ACTION_WIDTH,
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
justifyContent: 'center',
|
|
89
|
+
gap: 4,
|
|
90
|
+
paddingHorizontal: theme.spacing.xs,
|
|
91
|
+
backgroundColor: toneColor(tone),
|
|
92
|
+
opacity: pressed ? 0.85 : 1,
|
|
93
|
+
})}
|
|
94
|
+
>
|
|
95
|
+
<Icon name={action.icon} size={20} color="#FFFFFF" strokeWidth={2.25} />
|
|
96
|
+
<Text style={{ fontSize: 10, fontWeight: '700', color: '#FFFFFF', textAlign: 'center' }} numberOfLines={1}>
|
|
97
|
+
{action.label}
|
|
98
|
+
</Text>
|
|
99
|
+
</Pressable>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</Animated.View>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ListRow({
|
|
107
|
+
title,
|
|
108
|
+
subtitle,
|
|
109
|
+
captions,
|
|
110
|
+
amount,
|
|
111
|
+
badgeLabel,
|
|
112
|
+
badgeTone = 'neutral',
|
|
113
|
+
onPress,
|
|
114
|
+
footer,
|
|
115
|
+
icon,
|
|
116
|
+
swipeActions,
|
|
117
|
+
}: ListRowProps) {
|
|
118
|
+
const theme = useObiTheme();
|
|
119
|
+
const visibleCaptions = (captions ?? []).filter(Boolean) as string[];
|
|
120
|
+
const actions = (swipeActions ?? []).slice(0, MAX_SWIPE_ACTIONS);
|
|
121
|
+
const swipeRef = useRef<SwipeableMethods>(null);
|
|
122
|
+
const closeSelf = useCallback(() => swipeRef.current?.close(), []);
|
|
123
|
+
|
|
124
|
+
const content = (
|
|
125
|
+
<>
|
|
126
|
+
{icon ? (
|
|
127
|
+
<View style={{ width: 38, height: 38, borderRadius: theme.radius.md, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.glass.primaryTint }}>
|
|
128
|
+
{icon}
|
|
129
|
+
</View>
|
|
130
|
+
) : null}
|
|
131
|
+
<View style={{ flex: 1, gap: 3 }}>
|
|
132
|
+
<Text style={{ fontSize: theme.type.body, fontWeight: '700', color: theme.colors.text }} numberOfLines={1}>{title}</Text>
|
|
133
|
+
{subtitle ? <Text style={{ fontSize: 13, color: theme.colors.textMuted }} numberOfLines={1}>{subtitle}</Text> : null}
|
|
134
|
+
{visibleCaptions.length > 0 ? (
|
|
135
|
+
<View style={{ flexDirection: 'row', gap: theme.spacing.sm, flexWrap: 'wrap' }}>
|
|
136
|
+
{visibleCaptions.map((caption, index) => (
|
|
137
|
+
<Text key={`${caption}-${index}`} style={{ fontSize: 11, color: theme.colors.textMuted }}>{caption}</Text>
|
|
138
|
+
))}
|
|
139
|
+
</View>
|
|
140
|
+
) : null}
|
|
141
|
+
</View>
|
|
142
|
+
{amount || badgeLabel ? (
|
|
143
|
+
<View style={{ alignItems: 'flex-end', gap: theme.spacing.xs }}>
|
|
144
|
+
{amount ? <Text style={{ fontSize: theme.type.body, fontWeight: '800', color: theme.colors.text }}>{amount}</Text> : null}
|
|
145
|
+
{badgeLabel ? <StatusBadge label={badgeLabel} tone={badgeTone} /> : null}
|
|
146
|
+
</View>
|
|
147
|
+
) : null}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const itemStyle = ({ pressed }: { pressed: boolean }) => [
|
|
152
|
+
{
|
|
153
|
+
flexDirection: 'row' as const,
|
|
154
|
+
alignItems: 'center' as const,
|
|
155
|
+
padding: theme.spacing.lg,
|
|
156
|
+
gap: theme.spacing.md,
|
|
157
|
+
backgroundColor: actions.length ? theme.colors.surface : pressed ? theme.colors.surfacePressed : 'transparent',
|
|
158
|
+
paddingBottom: footer && !actions.length ? theme.spacing.sm : theme.spacing.lg,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const row = onPress ? (
|
|
163
|
+
<Pressable onPress={onPress} style={itemStyle}>{content}</Pressable>
|
|
164
|
+
) : (
|
|
165
|
+
<View style={itemStyle({ pressed: false })}>{content}</View>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (actions.length === 0) {
|
|
169
|
+
return (
|
|
170
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: theme.colors.separator, backgroundColor: theme.glass.surface }}>
|
|
171
|
+
{row}
|
|
172
|
+
{footer ? (
|
|
173
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.sm, paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.md }}>
|
|
174
|
+
{footer}
|
|
175
|
+
</View>
|
|
176
|
+
) : null}
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Swipeable
|
|
183
|
+
ref={swipeRef}
|
|
184
|
+
friction={2}
|
|
185
|
+
rightThreshold={40}
|
|
186
|
+
overshootRight={false}
|
|
187
|
+
containerStyle={{ borderBottomWidth: 1, borderBottomColor: theme.colors.separator, backgroundColor: theme.colors.surface, overflow: 'hidden' }}
|
|
188
|
+
childrenContainerStyle={{ backgroundColor: theme.colors.surface }}
|
|
189
|
+
onSwipeableWillOpen={() => claimSwipeOpen(closeSelf)}
|
|
190
|
+
onSwipeableClose={() => releaseSwipeOpen(closeSelf)}
|
|
191
|
+
renderRightActions={(progress, _translation, methods) => (
|
|
192
|
+
<SwipeActionsPanel actions={actions} progress={progress} methods={methods} />
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{row}
|
|
196
|
+
</Swipeable>
|
|
197
|
+
);
|
|
198
|
+
}
|