@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
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
|
+
}
|