@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.
Files changed (61) hide show
  1. package/README.md +45 -0
  2. package/package.json +52 -0
  3. package/src/components/AppHeader.tsx +67 -0
  4. package/src/components/BottomSheet.tsx +110 -0
  5. package/src/components/Button.tsx +117 -0
  6. package/src/components/Card.tsx +46 -0
  7. package/src/components/CenteredState.tsx +35 -0
  8. package/src/components/Chip.tsx +47 -0
  9. package/src/components/ChoiceField.tsx +44 -0
  10. package/src/components/DateRangeFilter.tsx +226 -0
  11. package/src/components/EmptyState.tsx +32 -0
  12. package/src/components/ErrorState.tsx +62 -0
  13. package/src/components/Fab.tsx +36 -0
  14. package/src/components/FormRow.tsx +39 -0
  15. package/src/components/FormWorkspaceShell.tsx +126 -0
  16. package/src/components/GlassTabBar.tsx +39 -0
  17. package/src/components/Icon.tsx +259 -0
  18. package/src/components/IconButton.tsx +73 -0
  19. package/src/components/ListFilterBar.tsx +134 -0
  20. package/src/components/ListRow.tsx +198 -0
  21. package/src/components/ListWorkspace.tsx +76 -0
  22. package/src/components/LoadingState.tsx +37 -0
  23. package/src/components/MetricCard.tsx +74 -0
  24. package/src/components/MetricsGrid.tsx +25 -0
  25. package/src/components/MiniBarChart.tsx +42 -0
  26. package/src/components/NavigationDrawer.tsx +243 -0
  27. package/src/components/QueryState.tsx +82 -0
  28. package/src/components/QueryWorkspace.tsx +74 -0
  29. package/src/components/ScanInputBar.tsx +69 -0
  30. package/src/components/ScannerShell.tsx +76 -0
  31. package/src/components/Screen.tsx +44 -0
  32. package/src/components/SearchArea.tsx +34 -0
  33. package/src/components/SegmentedControl.tsx +64 -0
  34. package/src/components/Skeleton.tsx +42 -0
  35. package/src/components/StatusBadge.tsx +23 -0
  36. package/src/components/SuccessState.tsx +41 -0
  37. package/src/components/TextField.tsx +47 -0
  38. package/src/components/ToggleRow.tsx +35 -0
  39. package/src/components/record-detail/RecordDetail.tsx +60 -0
  40. package/src/components/record-detail/RecordDetailActions.tsx +88 -0
  41. package/src/components/record-detail/RecordDetailField.tsx +43 -0
  42. package/src/components/record-detail/RecordDetailHero.tsx +46 -0
  43. package/src/components/record-detail/RecordDetailLine.tsx +36 -0
  44. package/src/components/record-detail/RecordDetailSection.tsx +36 -0
  45. package/src/components/record-detail/index.ts +7 -0
  46. package/src/components/record-detail/status.ts +16 -0
  47. package/src/gallery/catalog.ts +31 -0
  48. package/src/glass/AmbientBackground.tsx +29 -0
  49. package/src/glass/GlassSurface.tsx +107 -0
  50. package/src/index.ts +66 -0
  51. package/src/layout/Box.tsx +62 -0
  52. package/src/layout/Divider.tsx +8 -0
  53. package/src/layout/Row.tsx +7 -0
  54. package/src/layout/Stack.tsx +7 -0
  55. package/src/provider/ObiUIProvider.tsx +45 -0
  56. package/src/styles/useObiStyles.ts +288 -0
  57. package/src/theme/effects.ts +219 -0
  58. package/src/theme/index.ts +3 -0
  59. package/src/theme/theme.ts +53 -0
  60. package/src/theme/tokens.ts +133 -0
  61. package/src/typography/ObiText.tsx +52 -0
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @obisoft/ui
2
+
3
+ Design system reutilizable de Obisoft para React Native. Centraliza theme, Liquid Glass, primitives, formularios, navegación presentacional, listas, métricas, scanner, estados async y una capa de utilidades semánticas. No depende de Redux, APIs, React Navigation ni reglas de negocio de OBI Ventas.
4
+
5
+ ## Estado
6
+
7
+ Versión: **`0.8.0`** (npm público, scope `@obisoft`).
8
+
9
+ - Desarrollo en monorepo: `file:packages/obisoft-ui`
10
+ - Consumo externo: `npm i @obisoft/ui@0.8.0` (requiere `NPM_TOKEN` de la org)
11
+
12
+ Ver [docs/OBISOFT_UI_PUBLISH.md](../../docs/OBISOFT_UI_PUBLISH.md).
13
+
14
+ ## Capas
15
+
16
+ - `theme` / `provider` / `styles` / `layout` / `typography` / `glass`
17
+ - `components`: forms, acciones, listas, navegación visual, métricas, record detail, estados, rango de fechas y scanner
18
+ - `gallery`: catálogo público `OBI_UI_CATALOG`
19
+
20
+ ## Workspaces de pantalla
21
+
22
+ | Export | Uso |
23
+ |--------|-----|
24
+ | `ListWorkspace` | Listados FlashList |
25
+ | `QueryWorkspace` | Scroll/métricas (reportes, estados de cuenta) |
26
+ | `QueryState` / `LoadingState` / `ErrorState` / `EmptyState` | Estados async |
27
+ | `FormRow` | Filas de formularios CRUD |
28
+
29
+ ## Storybook
30
+
31
+ Catálogo visual fuera de la app de producto:
32
+
33
+ ```bash
34
+ npm run storybook
35
+ ```
36
+
37
+ Ver [`apps/obisoft-ui-storybook`](../../apps/obisoft-ui-storybook).
38
+
39
+ ## Metro (consumidores)
40
+
41
+ El package es source-first (`.ts`). Configura transpile + `watchFolders` / `extraNodeModules` como en este monorepo.
42
+
43
+ ## Regla de arquitectura
44
+
45
+ `@obisoft/ui` solo conoce React Native y dependencias visuales. Nunca importa Redux, APIs, features, pages ni `@obisoft/ventas-ui`. Validar con `npm run audit:ui`.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@obisoft/ui",
3
+ "version": "0.8.0",
4
+ "description": "Obisoft React Native design system with Liquid Glass, adaptive themes and reusable primitives.",
5
+ "license": "UNLICENSED",
6
+ "sideEffects": false,
7
+ "main": "./src/index.ts",
8
+ "react-native": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "react-native": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "react-native",
23
+ "design-system",
24
+ "obisoft",
25
+ "ui"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "registry": "https://registry.npmjs.org/"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/obisoft/obi-projects.git",
34
+ "directory": "obi-ventas-mobile/packages/obisoft-ui"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/obisoft/obi-projects/issues"
38
+ },
39
+ "homepage": "https://github.com/obisoft/obi-projects/tree/main/obi-ventas-mobile/packages/obisoft-ui#readme",
40
+ "peerDependencies": {
41
+ "react": ">=19.0.0",
42
+ "react-native": ">=0.80.0",
43
+ "react-native-safe-area-context": ">=5.0.0",
44
+ "react-native-linear-gradient": ">=2.8.0",
45
+ "@sbaiahmed1/react-native-blur": ">=6.0.0",
46
+ "lucide-react-native": ">=0.47.0",
47
+ "react-native-gesture-handler": ">=3.0.0",
48
+ "react-native-reanimated": ">=4.0.0",
49
+ "react-native-svg": ">=15.0.0",
50
+ "@react-native-community/datetimepicker": ">=8.0.0"
51
+ }
52
+ }
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useObiTheme } from '../provider/ObiUIProvider';
5
+ import { ObiText } from '../typography/ObiText';
6
+ import { GlassSurface } from '../glass/GlassSurface';
7
+ import { IconButton } from './IconButton';
8
+ import type { IconName } from './Icon';
9
+
10
+ export type AppHeaderProps = {
11
+ title: string;
12
+ subtitle?: string;
13
+ leftIcon?: IconName;
14
+ onLeftPress?: () => void;
15
+ leftAccessibilityLabel?: string;
16
+ right?: React.ReactNode;
17
+ safeArea?: boolean;
18
+ style?: StyleProp<ViewStyle>;
19
+ };
20
+
21
+ export function AppHeader({
22
+ title,
23
+ subtitle,
24
+ leftIcon,
25
+ onLeftPress,
26
+ leftAccessibilityLabel,
27
+ right,
28
+ safeArea = true,
29
+ style,
30
+ }: AppHeaderProps) {
31
+ const theme = useObiTheme();
32
+ const insets = useSafeAreaInsets();
33
+ return (
34
+ <GlassSurface
35
+ variant="chrome"
36
+ style={[
37
+ {
38
+ minHeight: theme.navigation.headerMinHeight,
39
+ flexDirection: 'row',
40
+ alignItems: 'center',
41
+ gap: theme.spacing.md,
42
+ paddingHorizontal: theme.spacing.lg,
43
+ paddingTop: (safeArea ? insets.top : 0) + theme.spacing.sm,
44
+ paddingBottom: theme.spacing.sm,
45
+ borderBottomWidth: 1,
46
+ borderBottomColor: theme.glass.borderSoft,
47
+ },
48
+ style,
49
+ ]}
50
+ >
51
+ {leftIcon && onLeftPress ? (
52
+ <IconButton
53
+ icon={leftIcon}
54
+ onPress={onLeftPress}
55
+ accessibilityLabel={leftAccessibilityLabel ?? (leftIcon === 'arrow-left' ? 'Volver' : 'Abrir menú')}
56
+ variant="ghost"
57
+ size="md"
58
+ />
59
+ ) : null}
60
+ <View style={{ flex: 1, minWidth: 0 }}>
61
+ <ObiText variant="subtitle" weight="900" numberOfLines={1}>{title}</ObiText>
62
+ {subtitle ? <ObiText variant="caption" muted numberOfLines={1}>{subtitle}</ObiText> : null}
63
+ </View>
64
+ {right ? <View style={{ marginLeft: theme.spacing.xs }}>{right}</View> : null}
65
+ </GlassSurface>
66
+ );
67
+ }
@@ -0,0 +1,110 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ KeyboardAvoidingView,
6
+ Modal,
7
+ Platform,
8
+ Pressable,
9
+ ScrollView,
10
+ StyleSheet,
11
+ View,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
16
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
+ import { useObiTheme } from '../provider/ObiUIProvider';
18
+ import { ObiText } from '../typography/ObiText';
19
+
20
+ type Props = {
21
+ visible: boolean;
22
+ onClose: () => void;
23
+ title?: string;
24
+ subtitle?: React.ReactNode;
25
+ children: React.ReactNode;
26
+ footer?: React.ReactNode;
27
+ contentStyle?: StyleProp<ViewStyle>;
28
+ minHeight?: number;
29
+ };
30
+
31
+ const SLIDE_IN_FROM = 420;
32
+ const OPEN_MS = 280;
33
+ const CLOSE_MS = 200;
34
+
35
+ export function BottomSheet({ visible, onClose, title, subtitle, children, footer, contentStyle, minHeight }: Props) {
36
+ const insets = useSafeAreaInsets();
37
+ const theme = useObiTheme();
38
+ const [mounted, setMounted] = useState(visible);
39
+ const backdrop = useRef(new Animated.Value(0)).current;
40
+ const slide = useRef(new Animated.Value(SLIDE_IN_FROM)).current;
41
+ const animRef = useRef<Animated.CompositeAnimation | null>(null);
42
+
43
+ useEffect(() => {
44
+ animRef.current?.stop();
45
+ if (visible) {
46
+ if (!mounted) {
47
+ backdrop.setValue(0);
48
+ slide.setValue(SLIDE_IN_FROM);
49
+ setMounted(true);
50
+ return;
51
+ }
52
+ backdrop.setValue(0);
53
+ slide.setValue(SLIDE_IN_FROM);
54
+ animRef.current = Animated.parallel([
55
+ Animated.timing(backdrop, { toValue: 1, duration: OPEN_MS, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
56
+ Animated.timing(slide, { toValue: 0, duration: OPEN_MS, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
57
+ ]);
58
+ animRef.current.start();
59
+ return;
60
+ }
61
+ if (!mounted) return;
62
+ animRef.current = Animated.parallel([
63
+ Animated.timing(backdrop, { toValue: 0, duration: CLOSE_MS, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
64
+ Animated.timing(slide, { toValue: 48, duration: CLOSE_MS, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
65
+ ]);
66
+ animRef.current.start(({ finished }) => { if (finished) setMounted(false); });
67
+ }, [visible, mounted, backdrop, slide]);
68
+
69
+ if (!mounted) return null;
70
+
71
+ return (
72
+ <Modal visible transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
73
+ <GestureHandlerRootView style={{ flex: 1 }}>
74
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
75
+ <View style={{ flex: 1 }}>
76
+ <Pressable style={{ flex: 1 }} onPress={onClose} accessibilityLabel="Cerrar">
77
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: theme.glass.backdropStrong, opacity: backdrop }]} />
78
+ </Pressable>
79
+ <Animated.View style={{ transform: [{ translateY: slide }] }}>
80
+ <View style={{
81
+ backgroundColor: theme.glass.panel,
82
+ borderTopLeftRadius: theme.radius.xl,
83
+ borderTopRightRadius: theme.radius.xl,
84
+ paddingTop: theme.spacing.sm,
85
+ paddingHorizontal: theme.spacing.lg,
86
+ paddingBottom: theme.spacing.lg + insets.bottom,
87
+ maxHeight: '88%',
88
+ overflow: 'hidden',
89
+ ...(minHeight != null ? { minHeight } : {}),
90
+ }}>
91
+ <View style={{ alignSelf: 'center', width: 36, height: 4, borderRadius: 2, backgroundColor: theme.glass.border, marginBottom: theme.spacing.md }} />
92
+ {title ? <ObiText variant="caption" weight="700" muted style={{ letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: theme.spacing.md }}>{title}</ObiText> : null}
93
+ {subtitle ? <View style={{ marginBottom: theme.spacing.md, gap: 2 }}>{subtitle}</View> : null}
94
+ <ScrollView
95
+ keyboardShouldPersistTaps="handled"
96
+ showsVerticalScrollIndicator={false}
97
+ bounces={false}
98
+ contentContainerStyle={[{ gap: theme.spacing.md, paddingBottom: theme.spacing.sm }, contentStyle]}
99
+ >
100
+ {children}
101
+ </ScrollView>
102
+ {footer ? <View style={{ gap: theme.spacing.sm, paddingTop: theme.spacing.md, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: theme.glass.borderSoft }}>{footer}</View> : null}
103
+ </View>
104
+ </Animated.View>
105
+ </View>
106
+ </KeyboardAvoidingView>
107
+ </GestureHandlerRootView>
108
+ </Modal>
109
+ );
110
+ }
@@ -0,0 +1,117 @@
1
+ import React from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ View,
6
+ type StyleProp,
7
+ type TextStyle,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import { useObiTheme } from '../provider/ObiUIProvider';
11
+ import { shadow } from '../theme/effects';
12
+ import { ObiText } from '../typography/ObiText';
13
+
14
+ type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'outline' | 'glass';
15
+ type ButtonSize = 'sm' | 'md' | 'lg';
16
+
17
+ export type ButtonProps = {
18
+ title?: string;
19
+ children?: React.ReactNode;
20
+ onPress?: () => void;
21
+ disabled?: boolean;
22
+ loading?: boolean;
23
+ variant?: ButtonVariant;
24
+ size?: ButtonSize;
25
+ style?: StyleProp<ViewStyle>;
26
+ contentStyle?: StyleProp<ViewStyle>;
27
+ textStyle?: StyleProp<TextStyle>;
28
+ left?: React.ReactNode;
29
+ right?: React.ReactNode;
30
+ testID?: string;
31
+ };
32
+
33
+ export function Button({
34
+ title,
35
+ children,
36
+ onPress,
37
+ disabled,
38
+ loading,
39
+ variant = 'primary',
40
+ size = 'md',
41
+ style,
42
+ contentStyle,
43
+ textStyle,
44
+ left,
45
+ right,
46
+ testID,
47
+ }: ButtonProps) {
48
+ const theme = useObiTheme();
49
+ const inert = Boolean(disabled || loading);
50
+
51
+ const backgroundColor = variant === 'primary'
52
+ ? theme.colors.primary
53
+ : variant === 'secondary'
54
+ ? theme.colors.secondary
55
+ : variant === 'danger'
56
+ ? theme.colors.danger
57
+ : variant === 'success'
58
+ ? theme.colors.success
59
+ : variant === 'glass'
60
+ ? theme.glass.control
61
+ : 'transparent';
62
+ const borderColor = variant === 'outline'
63
+ ? theme.colors.borderStrong
64
+ : variant === 'ghost' || variant === 'glass'
65
+ ? theme.glass.border
66
+ : 'transparent';
67
+ const foreground = variant === 'ghost' || variant === 'outline' || variant === 'glass'
68
+ ? theme.colors.primary
69
+ : theme.colors.onPrimary;
70
+ const minHeight = size === 'sm' ? 40 : size === 'lg' ? 54 : 48;
71
+ const padX = size === 'sm' ? theme.spacing.md : size === 'lg' ? theme.spacing.xl : theme.spacing.lg;
72
+
73
+ return (
74
+ <View style={style}>
75
+ <Pressable
76
+ testID={testID}
77
+ accessibilityRole="button"
78
+ accessibilityState={{ disabled: inert, busy: Boolean(loading) }}
79
+ disabled={inert}
80
+ onPress={onPress}
81
+ hitSlop={8}
82
+ style={({ pressed }) => [
83
+ {
84
+ minHeight,
85
+ paddingHorizontal: padX,
86
+ borderRadius: theme.radius.md,
87
+ alignItems: 'center',
88
+ justifyContent: 'center',
89
+ flexDirection: 'row',
90
+ gap: theme.spacing.sm,
91
+ backgroundColor,
92
+ borderWidth: variant === 'ghost' || variant === 'outline' || variant === 'glass' ? 1 : 0,
93
+ borderColor,
94
+ opacity: inert ? 0.45 : pressed ? 0.78 : 1,
95
+ ...(variant === 'primary' ? shadow.sm : variant === 'secondary' || variant === 'danger' || variant === 'success' ? shadow.xs : {}),
96
+ },
97
+ style ? { width: '100%', alignSelf: 'stretch' } : null,
98
+ contentStyle,
99
+ ]}
100
+ >
101
+ {loading ? (
102
+ <ActivityIndicator color={foreground} />
103
+ ) : (
104
+ <>
105
+ {left}
106
+ {typeof children === 'string' || typeof children === 'number' || title ? (
107
+ <ObiText weight="800" style={[{ color: foreground }, textStyle]}>
108
+ {title ?? children}
109
+ </ObiText>
110
+ ) : children}
111
+ {right}
112
+ </>
113
+ )}
114
+ </Pressable>
115
+ </View>
116
+ );
117
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { shadow } from '../theme/effects';
5
+
6
+ type CardVariant = 'default' | 'glass' | 'flat' | 'outlined' | 'elevated';
7
+
8
+ export function Card({
9
+ children,
10
+ style,
11
+ variant = 'glass',
12
+ padding,
13
+ }: {
14
+ children: React.ReactNode;
15
+ style?: StyleProp<ViewStyle>;
16
+ variant?: CardVariant;
17
+ padding?: number;
18
+ }) {
19
+ const theme = useObiTheme();
20
+ const isGlass = variant === 'glass' || variant === 'default';
21
+ return (
22
+ <View
23
+ style={[
24
+ {
25
+ backgroundColor: isGlass
26
+ ? theme.glass.surface
27
+ : variant === 'flat'
28
+ ? theme.colors.surfaceMuted
29
+ : theme.colors.surface,
30
+ borderRadius: theme.radius.lg,
31
+ padding: padding ?? theme.spacing.lg,
32
+ borderWidth: 1,
33
+ borderColor: isGlass
34
+ ? theme.glass.border
35
+ : variant === 'outlined'
36
+ ? theme.colors.borderStrong
37
+ : theme.colors.separator,
38
+ ...(variant === 'elevated' ? shadow.md : isGlass ? shadow.glass : {}),
39
+ },
40
+ style,
41
+ ]}
42
+ >
43
+ {children}
44
+ </View>
45
+ );
46
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+
6
+ export type CenteredStateProps = {
7
+ title?: string;
8
+ message?: string;
9
+ loading?: boolean;
10
+ action?: React.ReactNode;
11
+ children?: React.ReactNode;
12
+ style?: StyleProp<ViewStyle>;
13
+ };
14
+
15
+ export function CenteredState({ title, message, loading, action, children, style }: CenteredStateProps) {
16
+ const theme = useObiTheme();
17
+ return (
18
+ <View style={[
19
+ {
20
+ flex: 1,
21
+ alignItems: 'center',
22
+ justifyContent: 'center',
23
+ padding: theme.spacing.xxl,
24
+ gap: theme.spacing.md,
25
+ },
26
+ style,
27
+ ]}>
28
+ {loading ? <ActivityIndicator size="large" color={theme.colors.primary} /> : null}
29
+ {title ? <ObiText variant="subtitle" weight="900" style={{ textAlign: 'center' }}>{title}</ObiText> : null}
30
+ {message ? <ObiText muted style={{ textAlign: 'center', maxWidth: 460 }}>{message}</ObiText> : null}
31
+ {children}
32
+ {action}
33
+ </View>
34
+ );
35
+ }
@@ -0,0 +1,47 @@
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 { ObiText } from '../typography/ObiText';
6
+
7
+ export function Chip({
8
+ label,
9
+ selected,
10
+ onPress,
11
+ style,
12
+ }: {
13
+ label: string;
14
+ selected?: boolean;
15
+ onPress?: () => void;
16
+ style?: StyleProp<ViewStyle>;
17
+ }) {
18
+ const theme = useObiTheme();
19
+ return (
20
+ <Pressable
21
+ accessibilityRole="button"
22
+ accessibilityState={{ selected: Boolean(selected) }}
23
+ onPress={onPress}
24
+ style={({ pressed }) => [
25
+ {
26
+ paddingHorizontal: theme.spacing.md,
27
+ paddingVertical: theme.spacing.sm,
28
+ borderRadius: theme.radius.pill,
29
+ backgroundColor: selected ? theme.colors.primary : theme.glass.surface,
30
+ borderWidth: 1,
31
+ borderColor: selected ? theme.colors.primary : theme.glass.border,
32
+ opacity: pressed ? 0.76 : 1,
33
+ ...shadow.glassSm,
34
+ },
35
+ style,
36
+ ]}
37
+ >
38
+ <ObiText
39
+ variant="caption"
40
+ weight="700"
41
+ style={selected ? { color: theme.colors.onPrimary } : undefined}
42
+ >
43
+ {label}
44
+ </ObiText>
45
+ </Pressable>
46
+ );
47
+ }
@@ -0,0 +1,44 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { useObiTheme } from '../provider/ObiUIProvider';
4
+ import { ObiText } from '../typography/ObiText';
5
+ import { BottomSheet } from './BottomSheet';
6
+ import { Button } from './Button';
7
+ import { Card } from './Card';
8
+
9
+ export type ChoiceOption = { value: number | string; label: string };
10
+
11
+ export function ChoiceField({ label, value, options, placeholder = 'Seleccionar', onChange }: {
12
+ label: string;
13
+ value?: number | string | null;
14
+ options: ChoiceOption[];
15
+ placeholder?: string;
16
+ onChange: (value: number | string) => void;
17
+ }) {
18
+ const theme = useObiTheme();
19
+ const [open, setOpen] = useState(false);
20
+ const selected = options.find(option => String(option.value) === String(value));
21
+ return (
22
+ <>
23
+ <View style={{ gap: theme.spacing.xs }}>
24
+ <ObiText variant="caption" muted>{label}</ObiText>
25
+ <Button title={selected?.label || placeholder} variant="secondary" onPress={() => setOpen(true)} />
26
+ </View>
27
+ <BottomSheet visible={open} onClose={() => setOpen(false)} title={label}>
28
+ <View style={{ gap: theme.spacing.sm }}>
29
+ {options.map(option => {
30
+ const active = String(option.value) === String(value);
31
+ return (
32
+ <Pressable key={String(option.value)} onPress={() => { onChange(option.value); setOpen(false); }}>
33
+ <Card style={{ borderColor: active ? theme.colors.primary : theme.colors.border }}>
34
+ <ObiText weight="800">{option.label}</ObiText>
35
+ </Card>
36
+ </Pressable>
37
+ );
38
+ })}
39
+ {!options.length ? <ObiText muted>No hay opciones disponibles.</ObiText> : null}
40
+ </View>
41
+ </BottomSheet>
42
+ </>
43
+ );
44
+ }