@mrmeg/expo-ui 0.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.
Files changed (112) hide show
  1. package/README.md +96 -0
  2. package/dist/components/Accordion.d.ts +54 -0
  3. package/dist/components/Accordion.js +149 -0
  4. package/dist/components/Alert.d.ts +30 -0
  5. package/dist/components/Alert.js +25 -0
  6. package/dist/components/AnimatedView.d.ts +55 -0
  7. package/dist/components/AnimatedView.js +39 -0
  8. package/dist/components/Badge.d.ts +23 -0
  9. package/dist/components/Badge.js +74 -0
  10. package/dist/components/BottomSheet.d.ts +74 -0
  11. package/dist/components/BottomSheet.js +513 -0
  12. package/dist/components/Button.d.ts +129 -0
  13. package/dist/components/Button.js +216 -0
  14. package/dist/components/Card.d.ts +42 -0
  15. package/dist/components/Card.js +126 -0
  16. package/dist/components/Checkbox.d.ts +39 -0
  17. package/dist/components/Checkbox.js +96 -0
  18. package/dist/components/Collapsible.d.ts +67 -0
  19. package/dist/components/Collapsible.js +38 -0
  20. package/dist/components/Dialog.d.ts +140 -0
  21. package/dist/components/Dialog.js +167 -0
  22. package/dist/components/DismissKeyboard.d.ts +15 -0
  23. package/dist/components/DismissKeyboard.js +13 -0
  24. package/dist/components/Drawer.d.ts +74 -0
  25. package/dist/components/Drawer.js +423 -0
  26. package/dist/components/DropdownMenu.d.ts +120 -0
  27. package/dist/components/DropdownMenu.js +211 -0
  28. package/dist/components/EmptyState.d.ts +42 -0
  29. package/dist/components/EmptyState.js +58 -0
  30. package/dist/components/ErrorBoundary.d.ts +53 -0
  31. package/dist/components/ErrorBoundary.js +75 -0
  32. package/dist/components/Icon.d.ts +46 -0
  33. package/dist/components/Icon.js +40 -0
  34. package/dist/components/InputOTP.d.ts +72 -0
  35. package/dist/components/InputOTP.js +155 -0
  36. package/dist/components/Label.d.ts +61 -0
  37. package/dist/components/Label.js +72 -0
  38. package/dist/components/MaxWidthContainer.d.ts +58 -0
  39. package/dist/components/MaxWidthContainer.js +64 -0
  40. package/dist/components/Notification.d.ts +26 -0
  41. package/dist/components/Notification.js +230 -0
  42. package/dist/components/Popover.d.ts +79 -0
  43. package/dist/components/Popover.js +91 -0
  44. package/dist/components/Progress.d.ts +28 -0
  45. package/dist/components/Progress.js +107 -0
  46. package/dist/components/RadioGroup.d.ts +65 -0
  47. package/dist/components/RadioGroup.js +142 -0
  48. package/dist/components/Select.d.ts +88 -0
  49. package/dist/components/Select.js +172 -0
  50. package/dist/components/Separator.d.ts +83 -0
  51. package/dist/components/Separator.js +85 -0
  52. package/dist/components/Skeleton.d.ts +68 -0
  53. package/dist/components/Skeleton.js +99 -0
  54. package/dist/components/Slider.d.ts +24 -0
  55. package/dist/components/Slider.js +162 -0
  56. package/dist/components/StatusBar.d.ts +1 -0
  57. package/dist/components/StatusBar.js +19 -0
  58. package/dist/components/StyledText.d.ts +161 -0
  59. package/dist/components/StyledText.js +193 -0
  60. package/dist/components/Switch.d.ts +44 -0
  61. package/dist/components/Switch.js +129 -0
  62. package/dist/components/Tabs.d.ts +31 -0
  63. package/dist/components/Tabs.js +127 -0
  64. package/dist/components/TextInput.d.ts +120 -0
  65. package/dist/components/TextInput.js +263 -0
  66. package/dist/components/Toggle.d.ts +106 -0
  67. package/dist/components/Toggle.js +150 -0
  68. package/dist/components/ToggleGroup.d.ts +80 -0
  69. package/dist/components/ToggleGroup.js +189 -0
  70. package/dist/components/Tooltip.d.ts +121 -0
  71. package/dist/components/Tooltip.js +132 -0
  72. package/dist/components/index.d.ts +35 -0
  73. package/dist/components/index.js +35 -0
  74. package/dist/constants/colors.d.ts +82 -0
  75. package/dist/constants/colors.js +116 -0
  76. package/dist/constants/fonts.d.ts +32 -0
  77. package/dist/constants/fonts.js +91 -0
  78. package/dist/constants/index.d.ts +3 -0
  79. package/dist/constants/index.js +3 -0
  80. package/dist/constants/spacing.d.ts +40 -0
  81. package/dist/constants/spacing.js +48 -0
  82. package/dist/hooks/index.d.ts +6 -0
  83. package/dist/hooks/index.js +6 -0
  84. package/dist/hooks/useDimensions.d.ts +19 -0
  85. package/dist/hooks/useDimensions.js +55 -0
  86. package/dist/hooks/useReduceMotion.d.ts +5 -0
  87. package/dist/hooks/useReduceMotion.js +64 -0
  88. package/dist/hooks/useResources.d.ts +12 -0
  89. package/dist/hooks/useResources.js +56 -0
  90. package/dist/hooks/useScalePress.d.ts +57 -0
  91. package/dist/hooks/useScalePress.js +55 -0
  92. package/dist/hooks/useStaggeredEntrance.d.ts +67 -0
  93. package/dist/hooks/useStaggeredEntrance.js +74 -0
  94. package/dist/hooks/useTheme.d.ts +88 -0
  95. package/dist/hooks/useTheme.js +328 -0
  96. package/dist/index.d.ts +5 -0
  97. package/dist/index.js +5 -0
  98. package/dist/lib/animations.d.ts +1 -0
  99. package/dist/lib/animations.js +3 -0
  100. package/dist/lib/haptics.d.ts +3 -0
  101. package/dist/lib/haptics.js +29 -0
  102. package/dist/lib/index.d.ts +3 -0
  103. package/dist/lib/index.js +3 -0
  104. package/dist/lib/sentry.d.ts +16 -0
  105. package/dist/lib/sentry.js +55 -0
  106. package/dist/state/globalUIStore.d.ts +30 -0
  107. package/dist/state/globalUIStore.js +8 -0
  108. package/dist/state/index.d.ts +2 -0
  109. package/dist/state/index.js +2 -0
  110. package/dist/state/themeStore.d.ts +6 -0
  111. package/dist/state/themeStore.js +38 -0
  112. package/package.json +92 -0
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { palette } from "../constants/colors";
3
+ import { fontFamilies } from "../constants/fonts";
4
+ import { spacing } from "../constants/spacing";
5
+ import { useTheme } from "../hooks/useTheme";
6
+ import { hapticLight } from "../lib/haptics";
7
+ import * as SwitchPrimitives from "@rn-primitives/switch";
8
+ import { useCallback, useEffect, useRef } from "react";
9
+ import { ActivityIndicator, Platform, StyleSheet, View } from "react-native";
10
+ import Animated, { useSharedValue, useAnimatedStyle, withTiming, interpolate, useReducedMotion, } from "react-native-reanimated";
11
+ import { StyledText } from "./StyledText";
12
+ const DEFAULT_HIT_SLOP = 8;
13
+ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, ...props }) {
14
+ const { theme, getContrastingColor, withAlpha } = useTheme();
15
+ const reduceMotion = useReducedMotion();
16
+ const hasMounted = useRef(false);
17
+ // Fire haptic on user-initiated toggles (skip initial mount)
18
+ const wrappedOnCheckedChange = useCallback((checked) => {
19
+ if (hasMounted.current)
20
+ hapticLight();
21
+ props.onCheckedChange?.(checked);
22
+ }, [props.onCheckedChange]);
23
+ useEffect(() => {
24
+ hasMounted.current = true;
25
+ }, []);
26
+ // Single shared value drives everything: 0 = off, 1 = on
27
+ const progress = useSharedValue(props.checked ? 1 : 0);
28
+ useEffect(() => {
29
+ const target = props.checked ? 1 : 0;
30
+ if (reduceMotion) {
31
+ progress.value = withTiming(target, { duration: 0 });
32
+ }
33
+ else {
34
+ progress.value = withTiming(target, { duration: 120 });
35
+ }
36
+ }, [props.checked, reduceMotion]);
37
+ // Thumb slides from left to right
38
+ const thumbOffset = 2;
39
+ const thumbEnd = size.width - thumbSize - thumbOffset;
40
+ const thumbAnimatedStyle = useAnimatedStyle(() => ({
41
+ transform: [
42
+ { translateX: interpolate(progress.value, [0, 1], [thumbOffset, thumbEnd]) },
43
+ ],
44
+ }));
45
+ const isIOS = variant === "ios";
46
+ // Keep the default checked state on a stable dark neutral so the white thumb
47
+ // stays distinct in both light and dark themes.
48
+ const checkedColor = isIOS ? "#34C759" : palette.gray900;
49
+ const uncheckedColor = theme.dark ? withAlpha(palette.white, 0.18) : palette.gray200;
50
+ const trackBg = props.checked ? checkedColor : uncheckedColor;
51
+ const trackBorderColor = props.checked
52
+ ? theme.dark
53
+ ? withAlpha(palette.white, 0.18)
54
+ : withAlpha(palette.black, 0.08)
55
+ : theme.dark
56
+ ? withAlpha(palette.white, 0.14)
57
+ : palette.gray300;
58
+ const thumbBorderColor = theme.dark
59
+ ? withAlpha(palette.black, 0.24)
60
+ : withAlpha(palette.black, 0.12);
61
+ const thumbIndicatorColor = props.checked ? checkedColor : theme.colors.textDim;
62
+ // Calculate label color for ON state
63
+ const labelOnColor = getContrastingColor(checkedColor, palette.white, palette.black);
64
+ const labelFontSize = size.height / 3;
65
+ // Flatten style override for web compatibility
66
+ const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
67
+ return (_jsxs(SwitchPrimitives.Root, { ...props, onCheckedChange: wrappedOnCheckedChange, style: {
68
+ position: "relative",
69
+ width: size.width,
70
+ height: size.height,
71
+ borderRadius: size.height / 2,
72
+ justifyContent: "center",
73
+ opacity: props.disabled ? 0.5 : 1,
74
+ ...(Platform.OS === "web" && { cursor: "pointer" }),
75
+ ...(flattenedStyle || {}),
76
+ }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "switch", accessibilityState: {
77
+ checked: props.checked,
78
+ disabled: !!props.disabled,
79
+ busy: loading,
80
+ }, children: [_jsx(View, { style: {
81
+ ...StyleSheet.absoluteFillObject,
82
+ borderRadius: size.height / 2,
83
+ backgroundColor: trackBg,
84
+ borderWidth: 1,
85
+ borderColor: trackBorderColor,
86
+ }, pointerEvents: "none" }), labelOn && !isIOS && (_jsx(View, { style: {
87
+ position: "absolute",
88
+ left: spacing.sm,
89
+ justifyContent: "center",
90
+ alignItems: "center",
91
+ opacity: props.checked ? 1 : 0,
92
+ }, pointerEvents: "none", children: _jsx(StyledText, { style: {
93
+ fontFamily: fontFamilies.sansSerif.bold,
94
+ fontSize: labelFontSize,
95
+ color: labelOnColor,
96
+ userSelect: "none",
97
+ }, children: labelOn }) })), _jsx(SwitchPrimitives.Thumb, { children: _jsx(Animated.View, { style: [
98
+ {
99
+ width: thumbSize,
100
+ height: thumbSize,
101
+ borderRadius: thumbSize / 2,
102
+ backgroundColor: palette.white,
103
+ borderWidth: 1,
104
+ borderColor: thumbBorderColor,
105
+ justifyContent: "center",
106
+ alignItems: "center",
107
+ ...(Platform.OS !== "web" && {
108
+ shadowColor: "#000",
109
+ shadowOffset: { width: 0, height: 1 },
110
+ shadowOpacity: 0.15,
111
+ shadowRadius: 2,
112
+ elevation: 2,
113
+ }),
114
+ },
115
+ thumbAnimatedStyle,
116
+ ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
117
+ position: "absolute",
118
+ right: spacing.sm,
119
+ justifyContent: "center",
120
+ alignItems: "center",
121
+ opacity: props.checked ? 0 : 1,
122
+ }, pointerEvents: "none", children: _jsx(StyledText, { style: {
123
+ fontFamily: fontFamilies.sansSerif.bold,
124
+ fontSize: labelFontSize,
125
+ color: theme.colors.text,
126
+ userSelect: "none",
127
+ }, children: labelOff }) }))] }));
128
+ }
129
+ export { Switch };
@@ -0,0 +1,31 @@
1
+ import { type StyleProp, type ViewStyle } from "react-native";
2
+ import * as TabsPrimitive from "@rn-primitives/tabs";
3
+ import { type IconName } from "./Icon";
4
+ type TabsVariant = "underline" | "pill";
5
+ type TabsSize = "sm" | "md";
6
+ export interface TabsProps extends TabsPrimitive.RootProps {
7
+ variant?: TabsVariant;
8
+ size?: TabsSize;
9
+ }
10
+ declare function TabsRoot({ variant, size, children, ...props }: TabsProps): import("react/jsx-runtime").JSX.Element;
11
+ export interface TabsListProps extends TabsPrimitive.ListProps {
12
+ style?: StyleProp<ViewStyle>;
13
+ }
14
+ declare function TabsList({ style, children, ...props }: TabsListProps): import("react/jsx-runtime").JSX.Element;
15
+ export interface TabsTriggerProps extends TabsPrimitive.TriggerProps {
16
+ icon?: IconName;
17
+ style?: StyleProp<ViewStyle>;
18
+ }
19
+ declare function TabsTriggerInner({ icon, style, children, value, ...props }: TabsTriggerProps): import("react/jsx-runtime").JSX.Element;
20
+ declare const TabsTrigger: typeof TabsTriggerInner;
21
+ export interface TabsContentProps extends TabsPrimitive.ContentProps {
22
+ style?: StyleProp<ViewStyle>;
23
+ }
24
+ declare function TabsContent({ style, children, ...props }: TabsContentProps): import("react/jsx-runtime").JSX.Element;
25
+ declare const Tabs: typeof TabsRoot & {
26
+ List: typeof TabsList;
27
+ Trigger: typeof TabsTriggerInner;
28
+ Content: typeof TabsContent;
29
+ };
30
+ export { Tabs, TabsList, TabsTrigger, TabsContent, };
31
+ export type { TabsVariant, TabsSize, };
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Platform, StyleSheet, View } from "react-native";
4
+ import * as TabsPrimitive from "@rn-primitives/tabs";
5
+ import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
6
+ import { StyledText, TextClassContext, TextColorContext } from "./StyledText";
7
+ import { Icon } from "./Icon";
8
+ import { useTheme } from "../hooks/useTheme";
9
+ import { spacing } from "../constants/spacing";
10
+ // ============================================================================
11
+ // Size configs
12
+ // ============================================================================
13
+ const SIZE_CONFIGS = {
14
+ sm: {
15
+ height: 32,
16
+ paddingHorizontal: spacing.sm,
17
+ fontSize: 12,
18
+ iconSize: spacing.iconSm,
19
+ },
20
+ md: {
21
+ height: 36,
22
+ paddingHorizontal: spacing.md,
23
+ fontSize: 13,
24
+ iconSize: spacing.iconMd,
25
+ },
26
+ };
27
+ const TabsContext = React.createContext({
28
+ variant: "underline",
29
+ size: "md",
30
+ });
31
+ function useTabsContext() {
32
+ return React.useContext(TabsContext);
33
+ }
34
+ function TabsRoot({ variant = "underline", size = "md", children, ...props }) {
35
+ return (_jsx(TabsContext.Provider, { value: { variant, size }, children: _jsx(TabsPrimitive.Root, { ...props, children: children }) }));
36
+ }
37
+ function TabsList({ style, children, ...props }) {
38
+ const { theme } = useTheme();
39
+ const { variant } = useTabsContext();
40
+ const listStyle = variant === "pill"
41
+ ? {
42
+ flexDirection: "row",
43
+ backgroundColor: theme.colors.muted,
44
+ borderRadius: spacing.radiusMd,
45
+ padding: 2,
46
+ }
47
+ : {
48
+ flexDirection: "row",
49
+ borderBottomWidth: 1,
50
+ borderBottomColor: theme.colors.border,
51
+ };
52
+ return (_jsx(TabsPrimitive.List, { style: StyleSheet.flatten([listStyle, style]), ...props, children: children }));
53
+ }
54
+ function TabsTriggerInner({ icon, style, children, value, ...props }) {
55
+ const { theme } = useTheme();
56
+ const { variant, size } = useTabsContext();
57
+ const sizeConfig = SIZE_CONFIGS[size];
58
+ const reduceMotion = useReducedMotion();
59
+ const isDisabled = props.disabled ?? false;
60
+ // Determine selected state by comparing trigger value with root value
61
+ const rootContext = TabsPrimitive.useRootContext();
62
+ const isSelected = rootContext.value === value;
63
+ const activeOpacity = useSharedValue(isSelected ? 1 : 0);
64
+ React.useEffect(() => {
65
+ activeOpacity.value = reduceMotion
66
+ ? (isSelected ? 1 : 0)
67
+ : withTiming(isSelected ? 1 : 0, { duration: 200 });
68
+ }, [isSelected, reduceMotion]);
69
+ const indicatorStyle = useAnimatedStyle(() => ({
70
+ opacity: activeOpacity.value,
71
+ }));
72
+ const textColor = isDisabled
73
+ ? theme.colors.mutedForeground
74
+ : isSelected
75
+ ? theme.colors.foreground
76
+ : theme.colors.mutedForeground;
77
+ const triggerBaseStyle = {
78
+ flex: 1,
79
+ height: sizeConfig.height,
80
+ paddingHorizontal: sizeConfig.paddingHorizontal,
81
+ flexDirection: "row",
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ gap: spacing.xs,
85
+ opacity: isDisabled ? 0.5 : 1,
86
+ ...(Platform.OS === "web" && { cursor: isDisabled ? "default" : "pointer", outlineStyle: "none" }),
87
+ };
88
+ const pillActiveStyle = isSelected && variant === "pill" ? {
89
+ backgroundColor: theme.colors.background,
90
+ borderRadius: spacing.radiusSm,
91
+ } : {};
92
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(TabsPrimitive.Trigger, { value: value, style: StyleSheet.flatten([triggerBaseStyle, pillActiveStyle, style]), ...props, children: [_jsxs(View, { style: triggerContentStyles.container, children: [icon && (_jsx(Icon, { name: icon, size: sizeConfig.iconSize, color: textColor, decorative: true })), typeof children === "string" ? (_jsx(StyledText, { style: { fontSize: sizeConfig.fontSize }, children: children })) : children] }), variant === "underline" && (_jsx(Animated.View, { style: [
93
+ {
94
+ position: "absolute",
95
+ bottom: 0,
96
+ left: 0,
97
+ right: 0,
98
+ height: 2,
99
+ backgroundColor: theme.colors.foreground,
100
+ },
101
+ indicatorStyle,
102
+ ] }))] }) }) }));
103
+ }
104
+ const TabsTrigger = TabsTriggerInner;
105
+ function TabsContent({ style, children, ...props }) {
106
+ return (_jsx(TabsPrimitive.Content, { style: StyleSheet.flatten([{ marginTop: spacing.md }, style]), ...props, children: children }));
107
+ }
108
+ // ============================================================================
109
+ // Styles
110
+ // ============================================================================
111
+ const triggerContentStyles = StyleSheet.create({
112
+ container: {
113
+ flexDirection: "row",
114
+ alignItems: "center",
115
+ justifyContent: "center",
116
+ gap: spacing.xs,
117
+ },
118
+ });
119
+ // ============================================================================
120
+ // Compound Export
121
+ // ============================================================================
122
+ const Tabs = Object.assign(TabsRoot, {
123
+ List: TabsList,
124
+ Trigger: TabsTrigger,
125
+ Content: TabsContent,
126
+ });
127
+ export { Tabs, TabsList, TabsTrigger, TabsContent, };
@@ -0,0 +1,120 @@
1
+ import React, { ReactNode } from "react";
2
+ import { TextInput as RNTextInput, ViewStyle, TextStyle, TextInputProps, StyleProp } from "react-native";
3
+ /**
4
+ * Size variants for TextInput
5
+ */
6
+ export type TextInputSize = "sm" | "md" | "lg";
7
+ /**
8
+ * Visual variants for TextInput
9
+ */
10
+ export type TextInputVariant = "outline" | "filled" | "underlined";
11
+ interface TextInputCustomProps extends TextInputProps {
12
+ /**
13
+ * Visual variant
14
+ * @default "outline"
15
+ */
16
+ variant?: TextInputVariant;
17
+ /**
18
+ * Size variant
19
+ * @default "md"
20
+ */
21
+ size?: TextInputSize;
22
+ /**
23
+ * Label text displayed above the input
24
+ */
25
+ label?: string;
26
+ /**
27
+ * Helper text displayed below the input
28
+ */
29
+ helperText?: string;
30
+ /**
31
+ * Error message displayed below the input (overrides helperText)
32
+ */
33
+ errorText?: string;
34
+ /**
35
+ * Whether the input is in an error state
36
+ */
37
+ error?: boolean;
38
+ /**
39
+ * Whether the field is required (shows asterisk)
40
+ */
41
+ required?: boolean;
42
+ /**
43
+ * Number of rows for multiline input
44
+ */
45
+ rows?: number;
46
+ /**
47
+ * Whether to show the password visibility toggle
48
+ * Only applies when secureTextEntry is true
49
+ */
50
+ showSecureEntryToggle?: boolean;
51
+ /**
52
+ * Custom element to render on the left side of the input
53
+ */
54
+ leftElement?: ReactNode;
55
+ /**
56
+ * Custom element to render on the right side of the input
57
+ */
58
+ rightElement?: ReactNode;
59
+ /**
60
+ * Shows an X button to clear the input when it has a value.
61
+ * Not shown alongside showSecureEntryToggle or on multiline inputs.
62
+ * @default false
63
+ */
64
+ clearable?: boolean;
65
+ /**
66
+ * Wrapper view style
67
+ */
68
+ wrapperStyle?: StyleProp<ViewStyle>;
69
+ /**
70
+ * Style applied when input is focused
71
+ */
72
+ focusedStyle?: StyleProp<TextStyle>;
73
+ /**
74
+ * Force light theme colors (useful for dark backgrounds)
75
+ */
76
+ forceLight?: boolean;
77
+ }
78
+ /**
79
+ * Enhanced TextInput Component
80
+ *
81
+ * Features:
82
+ * - Size variants (sm, md, lg)
83
+ * - Visual variants (outline, filled, underlined)
84
+ * - Error states with error text
85
+ * - Helper text
86
+ * - Required indicator (asterisk)
87
+ * - Left/right custom elements
88
+ * - Password visibility toggle with eye/eye-off icons
89
+ * - Full accessibility support
90
+ * - Disabled state styling
91
+ *
92
+ * Usage:
93
+ * ```tsx
94
+ * // Basic
95
+ * <TextInput label="Email" placeholder="Enter email" />
96
+ *
97
+ * // With error
98
+ * <TextInput
99
+ * label="Email"
100
+ * error
101
+ * errorText="Email is required"
102
+ * />
103
+ *
104
+ * // With helper text
105
+ * <TextInput
106
+ * label="Password"
107
+ * helperText="Must be at least 8 characters"
108
+ * secureTextEntry
109
+ * showSecureEntryToggle
110
+ * />
111
+ *
112
+ * // With custom elements
113
+ * <TextInput
114
+ * label="Search"
115
+ * leftElement={<Icon as={Search} size={20} />}
116
+ * />
117
+ * ```
118
+ */
119
+ export declare const TextInput: React.ForwardRefExoticComponent<TextInputCustomProps & React.RefAttributes<RNTextInput>>;
120
+ export {};
@@ -0,0 +1,263 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState } from "react";
3
+ import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme";
5
+ import { spacing } from "../constants/spacing";
6
+ import { fontFamilies } from "../constants/fonts";
7
+ import { StyledText } from "./StyledText";
8
+ import { Icon } from "./Icon";
9
+ import { hapticLight } from "../lib/haptics";
10
+ import { palette } from "../constants/colors";
11
+ const NUMERIC_REGEX = /^[0-9]*$/;
12
+ const SIZE_CONFIGS = {
13
+ sm: {
14
+ height: 32,
15
+ fontSize: 13,
16
+ paddingVertical: spacing.xs,
17
+ paddingHorizontal: spacing.sm,
18
+ },
19
+ md: {
20
+ height: 36,
21
+ fontSize: 14,
22
+ paddingVertical: spacing.xs,
23
+ paddingHorizontal: spacing.sm,
24
+ },
25
+ lg: {
26
+ height: 40,
27
+ fontSize: 15,
28
+ paddingVertical: spacing.sm,
29
+ paddingHorizontal: spacing.md,
30
+ },
31
+ };
32
+ /**
33
+ * Enhanced TextInput Component
34
+ *
35
+ * Features:
36
+ * - Size variants (sm, md, lg)
37
+ * - Visual variants (outline, filled, underlined)
38
+ * - Error states with error text
39
+ * - Helper text
40
+ * - Required indicator (asterisk)
41
+ * - Left/right custom elements
42
+ * - Password visibility toggle with eye/eye-off icons
43
+ * - Full accessibility support
44
+ * - Disabled state styling
45
+ *
46
+ * Usage:
47
+ * ```tsx
48
+ * // Basic
49
+ * <TextInput label="Email" placeholder="Enter email" />
50
+ *
51
+ * // With error
52
+ * <TextInput
53
+ * label="Email"
54
+ * error
55
+ * errorText="Email is required"
56
+ * />
57
+ *
58
+ * // With helper text
59
+ * <TextInput
60
+ * label="Password"
61
+ * helperText="Must be at least 8 characters"
62
+ * secureTextEntry
63
+ * showSecureEntryToggle
64
+ * />
65
+ *
66
+ * // With custom elements
67
+ * <TextInput
68
+ * label="Search"
69
+ * leftElement={<Icon as={Search} size={20} />}
70
+ * />
71
+ * ```
72
+ */
73
+ export const TextInput = React.forwardRef(({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ...rest }, ref) => {
74
+ const { theme, getContrastingColor } = useTheme();
75
+ const styles = createStyles(theme, variant, size);
76
+ const [focused, setFocused] = useState(false);
77
+ const [contentHeight, setContentHeight] = useState(0);
78
+ const [passwordVisible, setPasswordVisible] = useState(false);
79
+ const isDisabled = editable === false;
80
+ const hasError = error || !!errorText;
81
+ // Determine background color
82
+ const backgroundColor = forceLight
83
+ ? palette.white
84
+ : variant === "filled"
85
+ ? theme.colors.card
86
+ : "transparent";
87
+ // Handle numeric input validation
88
+ const handleNumericChange = (input) => {
89
+ if (NUMERIC_REGEX.test(input)) {
90
+ onChangeText?.(input);
91
+ }
92
+ };
93
+ const handleTextChange = (input) => {
94
+ onChangeText?.(input);
95
+ };
96
+ const sizeConfig = SIZE_CONFIGS[size];
97
+ // Pre-calculate all values to avoid expensive recalculations on every keystroke
98
+ const borderColor = hasError
99
+ ? theme.colors.destructive
100
+ : focused
101
+ ? theme.colors.primary
102
+ : forceLight
103
+ ? "#d1d5db"
104
+ : theme.colors.border;
105
+ const inputPaddingLeft = leftElement
106
+ ? sizeConfig.paddingHorizontal + spacing.xl
107
+ : sizeConfig.paddingHorizontal;
108
+ const hasSecureToggle = !!(secureTextEntry && showSecureEntryToggle);
109
+ const showClearButton = clearable && !hasSecureToggle && !multiline && !isDisabled && !!value;
110
+ const hasRightSlot = !!rightElement || hasSecureToggle || showClearButton;
111
+ const showErrorIcon = hasError && !hasRightSlot && !multiline;
112
+ const inputPaddingRight = hasRightSlot || showErrorIcon
113
+ ? sizeConfig.paddingHorizontal + spacing.xl
114
+ : sizeConfig.paddingHorizontal;
115
+ const textColor = forceLight
116
+ ? "#1f2937"
117
+ : getContrastingColor(backgroundColor === "transparent" ? theme.colors.background : backgroundColor, theme.colors.text, palette.white);
118
+ const shouldScroll = multiline && rest.scrollEnabled !== false && contentHeight > 100;
119
+ const handleFocus = (e) => {
120
+ setFocused(true);
121
+ onFocus?.(e);
122
+ };
123
+ const handleBlur = (e) => {
124
+ setFocused(false);
125
+ onBlur?.(e);
126
+ };
127
+ const togglePasswordVisible = () => {
128
+ setPasswordVisible(v => !v);
129
+ };
130
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { style: styles.label, children: [label, required && _jsx(StyledText, { style: styles.required, children: " *" })] }) })), _jsxs(View, { style: styles.wrapper, children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: handleFocus, onBlur: handleBlur, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
131
+ styles.input,
132
+ {
133
+ backgroundColor,
134
+ borderColor,
135
+ color: textColor,
136
+ fontSize: sizeConfig.fontSize,
137
+ minHeight: multiline ? undefined : sizeConfig.height,
138
+ paddingVertical: sizeConfig.paddingVertical,
139
+ paddingLeft: inputPaddingLeft,
140
+ paddingRight: inputPaddingRight,
141
+ },
142
+ variant === "underlined" && styles.underlined,
143
+ variant === "filled" && styles.filled,
144
+ style,
145
+ focused && focusedStyle,
146
+ focused && Platform.OS === "web" && {
147
+ boxShadow: `0 0 0 2px ${theme.colors.background}, 0 0 0 4px ${theme.colors.primary}`,
148
+ },
149
+ isDisabled && styles.disabled,
150
+ hasError && styles.error,
151
+ Platform.OS === "web" && { fontSize: Math.max(sizeConfig.fontSize, 16) },
152
+ ], textAlignVertical: multiline ? "top" : "center", value: value, accessibilityLabel: label, accessibilityHint: helperText || errorText, accessibilityState: { disabled: isDisabled }, "aria-invalid": hasError, "aria-required": required }), showClearButton && !rightElement && (_jsx(Pressable, { style: styles.clearButton, onPress: () => {
153
+ hapticLight();
154
+ onChangeText?.("");
155
+ }, accessibilityLabel: "Clear input", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: spacing.iconSm, color: "textDim", decorative: true }) })), showClearButton && rightElement && !hasSecureToggle && (_jsxs(View, { style: styles.rightElements, children: [_jsx(Pressable, { onPress: () => {
156
+ hapticLight();
157
+ onChangeText?.("");
158
+ }, accessibilityLabel: "Clear input", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: spacing.iconSm, color: "textDim", decorative: true }) }), rightElement] })), !showClearButton && rightElement && !hasSecureToggle && (_jsx(View, { style: styles.rightElement, children: rightElement })), secureTextEntry && showSecureEntryToggle && (_jsx(Pressable, { style: styles.passwordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) })), showErrorIcon && (_jsx(View, { style: styles.errorIcon, accessibilityLabel: "Error", pointerEvents: "none", children: _jsx(Icon, { name: "alert-circle", size: spacing.iconSm, color: "destructive", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { style: [
159
+ styles.helperText,
160
+ hasError && styles.errorText,
161
+ ], children: errorText || helperText }))] }));
162
+ });
163
+ TextInput.displayName = "TextInput";
164
+ const createStyles = (theme, variant, size) => StyleSheet.create({
165
+ wrapper: {
166
+ width: "100%",
167
+ position: "relative",
168
+ backgroundColor: "transparent",
169
+ justifyContent: "center",
170
+ },
171
+ input: {
172
+ fontFamily: fontFamilies.sansSerif.regular,
173
+ borderRadius: spacing.radiusMd,
174
+ borderWidth: 1,
175
+ ...(Platform.OS === "web" && { outlineStyle: "none" }),
176
+ },
177
+ underlined: {
178
+ borderRadius: 0,
179
+ borderWidth: 0,
180
+ borderBottomWidth: 2,
181
+ },
182
+ filled: {
183
+ borderWidth: 0,
184
+ borderBottomWidth: 2,
185
+ },
186
+ disabled: {
187
+ opacity: 0.6,
188
+ ...(Platform.OS === "web" && { cursor: "not-allowed" }),
189
+ },
190
+ error: {
191
+ borderColor: theme.colors.destructive,
192
+ },
193
+ labelContainer: {
194
+ flexDirection: "row",
195
+ marginBottom: spacing.xs,
196
+ },
197
+ label: {
198
+ fontFamily: fontFamilies.sansSerif.regular,
199
+ fontWeight: "500",
200
+ fontSize: 14,
201
+ color: theme.colors.text,
202
+ },
203
+ required: {
204
+ color: theme.colors.destructive,
205
+ fontFamily: fontFamilies.sansSerif.bold,
206
+ },
207
+ helperText: {
208
+ fontFamily: fontFamilies.sansSerif.regular,
209
+ fontSize: 12,
210
+ color: theme.colors.textDim,
211
+ marginTop: spacing.xs,
212
+ },
213
+ errorText: {
214
+ color: theme.colors.destructive,
215
+ },
216
+ leftElement: {
217
+ position: "absolute",
218
+ left: spacing.sm,
219
+ top: "50%",
220
+ transform: [{ translateY: -10 }],
221
+ zIndex: 1,
222
+ },
223
+ rightElement: {
224
+ position: "absolute",
225
+ right: spacing.sm,
226
+ top: "50%",
227
+ transform: [{ translateY: -10 }],
228
+ zIndex: 1,
229
+ },
230
+ passwordToggle: {
231
+ position: "absolute",
232
+ right: spacing.sm,
233
+ top: "50%",
234
+ transform: [{ translateY: Platform.OS === "web" ? -10 : -12 }],
235
+ zIndex: 1,
236
+ ...(Platform.OS === "web" && { cursor: "pointer" }),
237
+ },
238
+ errorIcon: {
239
+ position: "absolute",
240
+ right: spacing.sm,
241
+ top: "50%",
242
+ transform: [{ translateY: -10 }],
243
+ zIndex: 1,
244
+ },
245
+ clearButton: {
246
+ position: "absolute",
247
+ right: spacing.sm,
248
+ top: "50%",
249
+ transform: [{ translateY: Platform.OS === "web" ? -10 : -12 }],
250
+ zIndex: 1,
251
+ ...(Platform.OS === "web" && { cursor: "pointer" }),
252
+ },
253
+ rightElements: {
254
+ position: "absolute",
255
+ right: spacing.sm,
256
+ top: 0,
257
+ bottom: 0,
258
+ flexDirection: "row",
259
+ alignItems: "center",
260
+ gap: spacing.xs,
261
+ zIndex: 1,
262
+ },
263
+ });