@urbint/cl 1.0.1-2 → 1.0.1-3

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/llms.txt CHANGED
@@ -766,7 +766,7 @@ interface RenderSelectedValueProps {
766
766
  ---
767
767
 
768
768
  #### DatePicker
769
- Date and time picker with multiple modes. Supports date only, time only, or date and time selection (side by side).
769
+ Date and time picker with multiple modes. Supports date only, time only, or date and time selection (side by side). Uses a `Modal`-based dropdown on iOS/Android. On web, consider using a native HTML `<input type="date|time|datetime-local">` for better browser integration (see "Cross-Platform Consumption" below).
770
770
 
771
771
  ```tsx
772
772
  import { DatePicker } from '@urbint/cl';
@@ -860,6 +860,286 @@ import { DatePicker } from '@urbint/cl';
860
860
  - `time`: Shows hour/minute/AM-PM columns with "Now" and "OK" buttons
861
861
  - `datetime`: Shows calendar and time picker side by side with "Now" and "OK" buttons
862
862
 
863
+ **Controlled vs Uncontrolled:**
864
+ - **Controlled:** Pass `value` (a `Date`) and `onChange`. The component reflects the `value` you give it.
865
+ - **Uncontrolled:** Omit `value` (or pass `undefined`). Optionally pass `defaultValue` for an initial selection. Internal state manages the selected date.
866
+ - **Important:** Do NOT switch between controlled (`value={someDate}`) and uncontrolled (`value={undefined}`) across re-renders. Pick one pattern. If you need to clear a controlled value, manage a separate `hasValue` flag rather than toggling `value` between a `Date` and `undefined`.
867
+
868
+ **Cross-Platform Consumption (iOS / Android / Web):**
869
+
870
+ On **iOS and Android** the DatePicker component works directly and renders a native `Modal` with the calendar/time picker. On **web** the component also works, but you may prefer native HTML inputs for better browser integration. A common pattern is to branch by platform:
871
+
872
+ ```tsx
873
+ import { Platform, View } from 'react-native';
874
+ import { DatePicker, HStack, Box, Pressable } from '@urbint/cl';
875
+ import { X } from 'lucide-react-native';
876
+
877
+ function DateTimeField({ value, onChange, onClear, label, mode }) {
878
+ // Web: use native HTML inputs for best browser experience
879
+ if (Platform.OS === 'web') {
880
+ const inputType = mode === 'date' ? 'date'
881
+ : mode === 'time' ? 'time'
882
+ : 'datetime-local';
883
+
884
+ return (
885
+ <Box>
886
+ <Text variant="caption" weight="semiBold">{label}</Text>
887
+ <input
888
+ type={inputType}
889
+ value={value ?? ''}
890
+ onChange={(e) => onChange(new Date(e.target.value))}
891
+ style={{ padding: 12, borderRadius: 4, border: '1px solid #ccc' }}
892
+ />
893
+ </Box>
894
+ );
895
+ }
896
+
897
+ // iOS / Android: use the DatePicker component
898
+ return (
899
+ <Box>
900
+ <HStack alignItems="flex-end" space="sm">
901
+ <View style={{ flex: 1 }}>
902
+ <DatePicker
903
+ label={label}
904
+ mode={mode}
905
+ value={value}
906
+ onChange={onChange}
907
+ />
908
+ </View>
909
+ {value && (
910
+ <Pressable onPress={onClear} style={{ padding: 12 }}>
911
+ <X size={18} color="#6B7280" />
912
+ </Pressable>
913
+ )}
914
+ </HStack>
915
+ </Box>
916
+ );
917
+ }
918
+ ```
919
+
920
+ **Full Real-World Example (form field with clear button):**
921
+
922
+ ```tsx
923
+ import React, { useState } from 'react';
924
+ import { View } from 'react-native';
925
+ import { DatePicker, HStack, Box, Pressable, Text } from '@urbint/cl';
926
+ import { X } from 'lucide-react-native';
927
+
928
+ // Helper formatters
929
+ const formatDate = (date: Date) => {
930
+ const m = String(date.getMonth() + 1).padStart(2, '0');
931
+ const d = String(date.getDate()).padStart(2, '0');
932
+ return `${m}/${d}/${date.getFullYear()}`;
933
+ };
934
+
935
+ const formatTime = (date: Date) => {
936
+ let h = date.getHours();
937
+ const min = String(date.getMinutes()).padStart(2, '0');
938
+ const ampm = h >= 12 ? 'PM' : 'AM';
939
+ h = h % 12 || 12;
940
+ return `${String(h).padStart(2, '0')}:${min} ${ampm}`;
941
+ };
942
+
943
+ const formatDateTime = (date: Date) => `${formatDate(date)} ${formatTime(date)}`;
944
+
945
+ function MyDateTimeField() {
946
+ const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
947
+
948
+ const handleChange = (date: Date) => {
949
+ setSelectedDate(date);
950
+ };
951
+
952
+ const handleClear = () => {
953
+ setSelectedDate(undefined);
954
+ };
955
+
956
+ const formatForPicker = (date: Date, mode: 'date' | 'time' | 'datetime') => {
957
+ if (mode === 'date') return formatDate(date);
958
+ if (mode === 'time') return formatTime(date);
959
+ return formatDateTime(date);
960
+ };
961
+
962
+ return (
963
+ <Box>
964
+ <HStack alignItems="flex-end" space="sm">
965
+ <View style={{ flex: 1 }}>
966
+ <DatePicker
967
+ label="Event Date & Time"
968
+ mode="datetime"
969
+ value={selectedDate}
970
+ onChange={handleChange}
971
+ placeholder="MM/DD/YYYY HH:mm"
972
+ isRequired
973
+ formatDate={formatForPicker}
974
+ showNowButton
975
+ />
976
+ </View>
977
+ {selectedDate && (
978
+ <Pressable onPress={handleClear} style={{ padding: 12, minHeight: 48, justifyContent: 'center' }}>
979
+ <X size={18} color="#6B7280" />
980
+ </Pressable>
981
+ )}
982
+ </HStack>
983
+ </Box>
984
+ );
985
+ }
986
+ ```
987
+
988
+ **Key Consumption Notes:**
989
+ 1. The `value` prop expects a `Date` object or `undefined`. Do not pass strings.
990
+ 2. The `onChange` callback receives a `Date` object.
991
+ 3. The `formatDate` prop signature is `(date: Date, mode: DatePickerMode) => string` — it controls how the selected value is displayed in the trigger.
992
+ 4. For **controlled** usage, always pass `value` as a `Date` or keep it consistently `undefined`; avoid toggling between the two.
993
+ 5. Wrap the DatePicker in a `View` with `flex: 1` when placing it inside an `HStack` so it takes available space.
994
+ 6. The dropdown uses a `Modal` internally — it renders at the root level, so parent `overflow: 'hidden'` will not clip it.
995
+
996
+ ---
997
+
998
+ #### Combobox
999
+ Autocomplete input with a dropdown list of suggestions. Supports async loading, custom item rendering, controlled/uncontrolled input, and cross-platform (iOS, Android, Web). Uses a Modal-based dropdown (same pattern as Select) to avoid overflow/zIndex issues.
1000
+
1001
+ ```tsx
1002
+ import { Combobox, ComboboxItem } from '@urbint/cl';
1003
+
1004
+ // Basic usage with local filtering
1005
+ const [value, setValue] = useState('');
1006
+ const [filtered, setFiltered] = useState(items);
1007
+
1008
+ const handleInputChange = (text: string) => {
1009
+ setValue(text);
1010
+ setFiltered(
1011
+ items.filter((item) =>
1012
+ item.label.toLowerCase().includes(text.toLowerCase())
1013
+ )
1014
+ );
1015
+ };
1016
+
1017
+ <Combobox
1018
+ label="Framework"
1019
+ placeholder="Search frameworks..."
1020
+ inputValue={value}
1021
+ onInputChange={handleInputChange}
1022
+ items={filtered}
1023
+ onSelect={(item) => setValue(item.label)}
1024
+ />
1025
+
1026
+ // Async loading with search icon
1027
+ const [query, setQuery] = useState('');
1028
+ const [results, setResults] = useState<ComboboxItem[]>([]);
1029
+ const [loading, setLoading] = useState(false);
1030
+
1031
+ const handleSearch = (text: string) => {
1032
+ setQuery(text);
1033
+ if (text.length < 2) { setResults([]); return; }
1034
+ setLoading(true);
1035
+ fetchResults(text).then((data) => {
1036
+ setResults(data);
1037
+ setLoading(false);
1038
+ });
1039
+ };
1040
+
1041
+ <Combobox
1042
+ label="Location"
1043
+ placeholder="Search a city..."
1044
+ inputValue={query}
1045
+ onInputChange={handleSearch}
1046
+ items={results}
1047
+ isLoading={loading}
1048
+ onSelect={(item) => setQuery(item.label)}
1049
+ minChars={2}
1050
+ leftElement={<Icon name="search" />}
1051
+ />
1052
+
1053
+ // With error state
1054
+ <Combobox
1055
+ label="Location"
1056
+ placeholder="Search..."
1057
+ items={[]}
1058
+ isInvalid
1059
+ errorMessage="Location is required"
1060
+ />
1061
+
1062
+ // Disabled
1063
+ <Combobox
1064
+ label="Location"
1065
+ placeholder="Search..."
1066
+ items={[]}
1067
+ isDisabled
1068
+ />
1069
+
1070
+ // Custom item rendering
1071
+ <Combobox
1072
+ items={users}
1073
+ renderItem={({ item, isHighlighted, searchQuery }) => (
1074
+ <HStack space="sm">
1075
+ <Avatar name={item.label} size="xs" />
1076
+ <VStack>
1077
+ <Text weight="semiBold">{item.label}</Text>
1078
+ <Text variant="caption">{item.description}</Text>
1079
+ </VStack>
1080
+ </HStack>
1081
+ )}
1082
+ />
1083
+ ```
1084
+
1085
+ **Props:**
1086
+ | Prop | Type | Default | Description |
1087
+ |------|------|---------|-------------|
1088
+ | items | ComboboxItem[] | required | Available items to display in the dropdown |
1089
+ | inputValue | string | - | Controlled input value |
1090
+ | onInputChange | (text: string) => void | - | Called when input text changes (for search/filtering) |
1091
+ | onSelect | (item: ComboboxItem) => void | - | Called when an item is selected |
1092
+ | placeholder | string | 'Search...' | Placeholder text |
1093
+ | label | string | - | Label text above the input |
1094
+ | helperText | string | - | Helper text below the input |
1095
+ | errorMessage | string | - | Error message (replaces helperText) |
1096
+ | isInvalid | boolean | false | Error state |
1097
+ | isDisabled | boolean | false | Disable input |
1098
+ | isReadOnly | boolean | false | Read-only input |
1099
+ | isRequired | boolean | false | Required field |
1100
+ | isLoading | boolean | false | Show loading spinner in dropdown |
1101
+ | size | 'sm' \| 'md' \| 'lg' | 'md' | Input size |
1102
+ | variant | 'outline' \| 'filled' | 'outline' | Input variant |
1103
+ | emptyText | string | 'No results found' | Text shown when no items match |
1104
+ | loadingText | string | 'Loading...' | Text shown while loading |
1105
+ | leftElement | ReactNode | - | Left element (e.g. search icon) |
1106
+ | rightElement | ReactNode | - | Right element (shown left of chevron) |
1107
+ | renderItem | (props: RenderComboboxItemProps) => ReactNode | - | Custom render for dropdown items |
1108
+ | closeOnSelect | boolean | true | Close dropdown on item select |
1109
+ | maxDropdownHeight | number | 260 | Max height of the dropdown list |
1110
+ | minChars | number | 0 | Minimum characters before showing dropdown |
1111
+ | textInputProps | TextInputProps | - | Additional TextInput props |
1112
+
1113
+ **ComboboxItem:**
1114
+ ```ts
1115
+ interface ComboboxItem {
1116
+ value: string; // Unique identifier
1117
+ label: string; // Display label
1118
+ description?: string; // Optional secondary text
1119
+ disabled?: boolean; // Whether item is disabled
1120
+ [key: string]: any; // Additional custom data
1121
+ }
1122
+ ```
1123
+
1124
+ **RenderComboboxItemProps:**
1125
+ ```ts
1126
+ interface RenderComboboxItemProps {
1127
+ item: ComboboxItem;
1128
+ isHighlighted: boolean;
1129
+ isDisabled: boolean;
1130
+ searchQuery: string;
1131
+ }
1132
+ ```
1133
+
1134
+ **Controlled vs Uncontrolled:**
1135
+ - **Controlled:** Pass `inputValue` and `onInputChange`. You manage the filtering and pass filtered `items`.
1136
+ - **Uncontrolled:** Omit `inputValue`. The component manages internal input state. You still need to pass `items` (the component does not filter for you).
1137
+
1138
+ **Key Differences from Select:**
1139
+ - Combobox has a text input for typing/searching; Select has a trigger button.
1140
+ - Combobox is ideal for autocomplete, search-as-you-type, and async data fetching scenarios.
1141
+ - Select is better for static, predefined option lists.
1142
+
863
1143
  ---
864
1144
 
865
1145
  #### Slider
@@ -1779,6 +2059,9 @@ import type {
1779
2059
  ModalProps,
1780
2060
  ToastOptions,
1781
2061
  SpacingToken, // Type for spacing tokens
2062
+ ComboboxProps,
2063
+ ComboboxItem,
2064
+ RenderComboboxItemProps,
1782
2065
  } from '@urbint/cl';
1783
2066
  ```
1784
2067
 
@@ -1888,6 +2171,6 @@ Once configured, ask your AI assistant:
1888
2171
 
1889
2172
  ---
1890
2173
 
1891
- *Last updated: January 2026*
1892
- *Documentation version: 1.1.0*
2174
+ *Last updated: February 2026*
2175
+ *Documentation version: 1.2.0*
1893
2176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbint/cl",
3
- "version": "1.0.1-2",
3
+ "version": "1.0.1-3",
4
4
  "description": "Enterprise-ready React Native component library built with Unistyles",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Combobox Stories
3
+ * Autocomplete input with dropdown suggestions
4
+ */
5
+
6
+ import React, { useState, useCallback } from 'react';
7
+ import type { Meta, StoryObj } from '@storybook/react';
8
+ import { View, Text } from 'react-native';
9
+ import { Combobox, ComboboxItem } from './Combobox';
10
+ import Svg, { Path } from 'react-native-svg';
11
+
12
+ const meta: Meta<typeof Combobox> = {
13
+ title: 'Form/Combobox',
14
+ component: Combobox,
15
+ decorators: [
16
+ (Story) => (
17
+ <View style={{ padding: 24, maxWidth: 400, minHeight: 400 }}>
18
+ <Story />
19
+ </View>
20
+ ),
21
+ ],
22
+ };
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof Combobox>;
26
+
27
+ // Sample data
28
+ const frameworks: ComboboxItem[] = [
29
+ { value: 'next', label: 'Next.js', description: 'The React framework' },
30
+ { value: 'svelte', label: 'SvelteKit', description: 'Cybernetically enhanced web apps' },
31
+ { value: 'nuxt', label: 'Nuxt.js', description: 'The intuitive Vue framework' },
32
+ { value: 'remix', label: 'Remix', description: 'Full stack web framework' },
33
+ { value: 'astro', label: 'Astro', description: 'The web framework for content-driven websites' },
34
+ ];
35
+
36
+ const cities: ComboboxItem[] = [
37
+ { value: 'ny', label: 'New York, NY, United States' },
38
+ { value: 'sf', label: 'San Francisco, CA, United States' },
39
+ { value: 'la', label: 'Los Angeles, CA, United States' },
40
+ { value: 'chicago', label: 'Chicago, IL, United States' },
41
+ { value: 'seattle', label: 'Seattle, WA, United States' },
42
+ { value: 'boston', label: 'Boston, MA, United States' },
43
+ { value: 'miami', label: 'Miami, FL, United States' },
44
+ { value: 'denver', label: 'Denver, CO, United States' },
45
+ ];
46
+
47
+ // ─── Basic ──────────────────────────────────────────────────────────────────
48
+
49
+ export const Basic: Story = {
50
+ render: () => {
51
+ const [value, setValue] = useState('');
52
+ const [filtered, setFiltered] = useState(frameworks);
53
+
54
+ const handleInputChange = (text: string) => {
55
+ setValue(text);
56
+ setFiltered(
57
+ frameworks.filter((f) =>
58
+ f.label.toLowerCase().includes(text.toLowerCase())
59
+ )
60
+ );
61
+ };
62
+
63
+ return (
64
+ <Combobox
65
+ label="Framework"
66
+ placeholder="Search frameworks..."
67
+ inputValue={value}
68
+ onInputChange={handleInputChange}
69
+ items={filtered}
70
+ onSelect={(item) => setValue(item.label)}
71
+ />
72
+ );
73
+ },
74
+ };
75
+
76
+ // ─── With Descriptions ──────────────────────────────────────────────────────
77
+
78
+ export const WithDescriptions: Story = {
79
+ render: () => {
80
+ const [value, setValue] = useState('');
81
+ const [filtered, setFiltered] = useState(frameworks);
82
+
83
+ const handleInputChange = (text: string) => {
84
+ setValue(text);
85
+ setFiltered(
86
+ frameworks.filter((f) =>
87
+ f.label.toLowerCase().includes(text.toLowerCase())
88
+ )
89
+ );
90
+ };
91
+
92
+ return (
93
+ <Combobox
94
+ label="Framework"
95
+ placeholder="Search frameworks..."
96
+ inputValue={value}
97
+ onInputChange={handleInputChange}
98
+ items={filtered}
99
+ onSelect={(item) => setValue(item.label)}
100
+ helperText="Choose a framework for your project"
101
+ />
102
+ );
103
+ },
104
+ };
105
+
106
+ // ─── Async Loading ──────────────────────────────────────────────────────────
107
+
108
+ export const AsyncLoading: Story = {
109
+ render: () => {
110
+ const [value, setValue] = useState('');
111
+ const [results, setResults] = useState<ComboboxItem[]>([]);
112
+ const [loading, setLoading] = useState(false);
113
+
114
+ const handleInputChange = useCallback((text: string) => {
115
+ setValue(text);
116
+ if (text.length < 2) {
117
+ setResults([]);
118
+ return;
119
+ }
120
+ setLoading(true);
121
+ // Simulate API call
122
+ setTimeout(() => {
123
+ setResults(
124
+ cities.filter((c) =>
125
+ c.label.toLowerCase().includes(text.toLowerCase())
126
+ )
127
+ );
128
+ setLoading(false);
129
+ }, 800);
130
+ }, []);
131
+
132
+ return (
133
+ <Combobox
134
+ label="Location"
135
+ placeholder="Search a city..."
136
+ inputValue={value}
137
+ onInputChange={handleInputChange}
138
+ items={results}
139
+ isLoading={loading}
140
+ onSelect={(item) => setValue(item.label)}
141
+ minChars={2}
142
+ leftElement={
143
+ <Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
144
+ <Path
145
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
146
+ stroke="#6B7280"
147
+ strokeWidth={2}
148
+ strokeLinecap="round"
149
+ strokeLinejoin="round"
150
+ />
151
+ </Svg>
152
+ }
153
+ />
154
+ );
155
+ },
156
+ };
157
+
158
+ // ─── With Error ─────────────────────────────────────────────────────────────
159
+
160
+ export const WithError: Story = {
161
+ render: () => (
162
+ <Combobox
163
+ label="Location"
164
+ placeholder="Search..."
165
+ items={[]}
166
+ isInvalid
167
+ errorMessage="Location is required"
168
+ />
169
+ ),
170
+ };
171
+
172
+ // ─── Disabled ───────────────────────────────────────────────────────────────
173
+
174
+ export const Disabled: Story = {
175
+ render: () => (
176
+ <Combobox
177
+ label="Location"
178
+ placeholder="Search..."
179
+ items={[]}
180
+ isDisabled
181
+ />
182
+ ),
183
+ };
184
+
185
+ // ─── Sizes ──────────────────────────────────────────────────────────────────
186
+
187
+ export const Sizes: Story = {
188
+ render: () => (
189
+ <View style={{ gap: 16 }}>
190
+ <Combobox label="Small" placeholder="Search..." items={frameworks} size="sm" />
191
+ <Combobox label="Medium" placeholder="Search..." items={frameworks} size="md" />
192
+ <Combobox label="Large" placeholder="Search..." items={frameworks} size="lg" />
193
+ </View>
194
+ ),
195
+ };
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Combobox Component
3
+ * Autocomplete input with a dropdown list of suggestions.
4
+ * Supports async loading, custom rendering, and cross-platform (iOS, Android, Web).
5
+ *
6
+ * Inspired by shadcn/ui Combobox, built with React Native primitives.
7
+ * Uses Modal-based dropdown (same pattern as Select) to avoid overflow/zIndex issues.
8
+ */
9
+
10
+ import React, { forwardRef, useState, useRef, useCallback, useEffect } from 'react';
11
+ import {
12
+ View,
13
+ ViewProps,
14
+ Pressable,
15
+ Text,
16
+ ScrollView,
17
+ TextInput,
18
+ TextInputProps,
19
+ StyleSheet,
20
+ Platform,
21
+ LayoutRectangle,
22
+ ActivityIndicator,
23
+ } from 'react-native';
24
+ import Svg, { Path } from 'react-native-svg';
25
+ import { colors, spacing, borderRadius, typography, elevation } from '../../styles/tokens';
26
+
27
+ // Web portal helper — renders dropdown at document.body to escape all parent overflow/zIndex
28
+ let createPortal: ((children: React.ReactNode, container: Element) => React.ReactPortal) | undefined;
29
+ if (Platform.OS === 'web') {
30
+ try {
31
+ createPortal = require('react-dom').createPortal;
32
+ } catch {}
33
+ }
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ export interface ComboboxItem {
40
+ /** Unique identifier */
41
+ value: string;
42
+ /** Display label */
43
+ label: string;
44
+ /** Optional secondary text (e.g. subtitle, description) */
45
+ description?: string;
46
+ /** Whether the item is disabled */
47
+ disabled?: boolean;
48
+ /** Any additional data for custom rendering */
49
+ [key: string]: any;
50
+ }
51
+
52
+ export interface RenderComboboxItemProps {
53
+ item: ComboboxItem;
54
+ isHighlighted: boolean;
55
+ isDisabled: boolean;
56
+ searchQuery: string;
57
+ }
58
+
59
+ export interface ComboboxProps extends Omit<ViewProps, 'children'> {
60
+ /** Available items to display in the dropdown */
61
+ items: ComboboxItem[];
62
+
63
+ /** Current input value (controlled) */
64
+ inputValue?: string;
65
+
66
+ /** Called when the input text changes (for async search) */
67
+ onInputChange?: (text: string) => void;
68
+
69
+ /** Called when an item is selected */
70
+ onSelect?: (item: ComboboxItem) => void;
71
+
72
+ /** Placeholder text for the input */
73
+ placeholder?: string;
74
+
75
+ /** Label text above the input */
76
+ label?: string;
77
+
78
+ /** Helper text below the input */
79
+ helperText?: string;
80
+
81
+ /** Error message (replaces helperText) */
82
+ errorMessage?: string;
83
+
84
+ /** Whether the input is invalid */
85
+ isInvalid?: boolean;
86
+
87
+ /** Whether the input is disabled */
88
+ isDisabled?: boolean;
89
+
90
+ /** Whether the input is read-only */
91
+ isReadOnly?: boolean;
92
+
93
+ /** Whether the input is required */
94
+ isRequired?: boolean;
95
+
96
+ /** Show a loading spinner in the dropdown */
97
+ isLoading?: boolean;
98
+
99
+ /** Input size */
100
+ size?: 'sm' | 'md' | 'lg';
101
+
102
+ /** Input variant */
103
+ variant?: 'outline' | 'filled';
104
+
105
+ /** Text shown when no items match */
106
+ emptyText?: string;
107
+
108
+ /** Text shown while loading */
109
+ loadingText?: string;
110
+
111
+ /** Left element (e.g. search icon) */
112
+ leftElement?: React.ReactNode;
113
+
114
+ /** Right element (e.g. clear button) — shown to the left of the chevron */
115
+ rightElement?: React.ReactNode;
116
+
117
+ /** Custom render function for dropdown items */
118
+ renderItem?: (props: RenderComboboxItemProps) => React.ReactNode;
119
+
120
+ /** Whether to close dropdown on select (default: true) */
121
+ closeOnSelect?: boolean;
122
+
123
+ /** Max height of the dropdown list */
124
+ maxDropdownHeight?: number;
125
+
126
+ /** Minimum characters before showing dropdown (default: 0) */
127
+ minChars?: number;
128
+
129
+ /** Additional TextInput props */
130
+ textInputProps?: Omit<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'editable'>;
131
+ }
132
+
133
+ // =============================================================================
134
+ // Component
135
+ // =============================================================================
136
+
137
+ export const Combobox = forwardRef<View, ComboboxProps>(
138
+ (
139
+ {
140
+ style,
141
+ items,
142
+ inputValue: controlledInputValue,
143
+ onInputChange,
144
+ onSelect,
145
+ placeholder = 'Search...',
146
+ label,
147
+ helperText,
148
+ errorMessage,
149
+ isInvalid = false,
150
+ isDisabled = false,
151
+ isReadOnly = false,
152
+ isRequired = false,
153
+ isLoading = false,
154
+ size = 'md',
155
+ variant = 'outline',
156
+ emptyText = 'No results found',
157
+ loadingText = 'Loading...',
158
+ leftElement,
159
+ rightElement,
160
+ renderItem,
161
+ closeOnSelect = true,
162
+ maxDropdownHeight = 260,
163
+ minChars = 0,
164
+ textInputProps,
165
+ ...props
166
+ },
167
+ ref
168
+ ) => {
169
+ const [isOpen, setIsOpen] = useState(false);
170
+ const [internalInputValue, setInternalInputValue] = useState('');
171
+ const [dropdownPosition, setDropdownPosition] = useState<LayoutRectangle | null>(null);
172
+ const [hoveredItem, setHoveredItem] = useState<string | null>(null);
173
+ const [isFocused, setIsFocused] = useState(false);
174
+
175
+ const inputRef = useRef<TextInput>(null);
176
+ const wrapperRef = useRef<View>(null);
177
+
178
+ const isControlled = controlledInputValue !== undefined;
179
+ const currentInputValue = isControlled ? controlledInputValue : internalInputValue;
180
+ const hasError = isInvalid || !!errorMessage;
181
+
182
+ // Measure and open the dropdown
183
+ const measureAndOpen = useCallback(() => {
184
+ wrapperRef.current?.measureInWindow((x, y, width, height) => {
185
+ setDropdownPosition({ x, y, width, height });
186
+ setIsOpen(true);
187
+ });
188
+ }, []);
189
+
190
+ // Open dropdown when input changes and meets minChars threshold
191
+ const handleInputChange = useCallback(
192
+ (text: string) => {
193
+ if (!isControlled) {
194
+ setInternalInputValue(text);
195
+ }
196
+ onInputChange?.(text);
197
+
198
+ if (text.length >= minChars) {
199
+ measureAndOpen();
200
+ } else {
201
+ setIsOpen(false);
202
+ }
203
+ },
204
+ [isControlled, onInputChange, minChars, measureAndOpen]
205
+ );
206
+
207
+ const handleSelect = useCallback(
208
+ (item: ComboboxItem) => {
209
+ if (item.disabled) return;
210
+
211
+ if (!isControlled) {
212
+ setInternalInputValue(item.label);
213
+ }
214
+ onSelect?.(item);
215
+
216
+ if (closeOnSelect) {
217
+ setIsOpen(false);
218
+ }
219
+ },
220
+ [isControlled, onSelect, closeOnSelect]
221
+ );
222
+
223
+ const handleClose = useCallback(() => {
224
+ setIsOpen(false);
225
+ setHoveredItem(null);
226
+ }, []);
227
+
228
+ const handleFocus = useCallback(() => {
229
+ setIsFocused(true);
230
+
231
+ // Show dropdown if we already have items and meet minChars
232
+ if (currentInputValue.length >= minChars && items.length > 0) {
233
+ wrapperRef.current?.measureInWindow((x, y, width, height) => {
234
+ setDropdownPosition({ x, y, width, height });
235
+ setIsOpen(true);
236
+ });
237
+ }
238
+ }, [currentInputValue, minChars, items.length]);
239
+
240
+ const handleBlur = useCallback(() => {
241
+ setIsFocused(false);
242
+ }, []);
243
+
244
+ const handleClear = useCallback(() => {
245
+ if (!isControlled) {
246
+ setInternalInputValue('');
247
+ }
248
+ onInputChange?.('');
249
+ setIsOpen(false);
250
+ inputRef.current?.focus();
251
+ }, [isControlled, onInputChange]);
252
+
253
+ // Close dropdown when input is below minChars and not loading
254
+ useEffect(() => {
255
+ if (isOpen && items.length === 0 && !isLoading && currentInputValue.length < minChars) {
256
+ setIsOpen(false);
257
+ }
258
+ }, [items.length, isLoading, isOpen, currentInputValue, minChars]);
259
+
260
+ // Open/re-measure when items arrive or loading starts (for async patterns)
261
+ useEffect(() => {
262
+ if ((items.length > 0 || isLoading) && currentInputValue.length >= minChars) {
263
+ measureAndOpen();
264
+ }
265
+ }, [items.length, isLoading, currentInputValue, minChars, measureAndOpen]);
266
+
267
+ const showClear = currentInputValue.length > 0 && !isDisabled && !isReadOnly;
268
+
269
+ // Calculate dropdown max height
270
+ const dropdownContentHeight = isLoading
271
+ ? 56
272
+ : items.length === 0
273
+ ? 56
274
+ : Math.min(items.length * 48 + 16, maxDropdownHeight);
275
+
276
+ // Dropdown content — extracted so it can be portalled on web
277
+ const dropdownOverlay = isOpen ? (
278
+ <>
279
+ {/* Backdrop: catches outside taps to close */}
280
+ <Pressable
281
+ style={[
282
+ styles.backdrop,
283
+ Platform.OS === 'web' && ({ position: 'fixed' } as any),
284
+ ]}
285
+ onPress={handleClose}
286
+ />
287
+
288
+ {/* Dropdown */}
289
+ <View
290
+ style={[
291
+ styles.dropdown,
292
+ Platform.OS === 'web' && ({ position: 'fixed' } as any),
293
+ dropdownPosition && {
294
+ top: dropdownPosition.y + dropdownPosition.height + 4,
295
+ left: dropdownPosition.x,
296
+ width: dropdownPosition.width,
297
+ maxHeight: dropdownContentHeight,
298
+ },
299
+ ]}
300
+ onStartShouldSetResponder={() => true}
301
+ >
302
+ {/* Loading State */}
303
+ {isLoading ? (
304
+ <View style={styles.stateContainer}>
305
+ <ActivityIndicator size="small" color={colors.brand.blue} />
306
+ <Text style={styles.stateText}>{loadingText}</Text>
307
+ </View>
308
+ ) : items.length === 0 ? (
309
+ /* Empty State */
310
+ <View style={styles.stateContainer}>
311
+ <Text style={styles.stateText}>{emptyText}</Text>
312
+ </View>
313
+ ) : (
314
+ /* Items List */
315
+ <ScrollView
316
+ style={styles.itemsList}
317
+ showsVerticalScrollIndicator
318
+ nestedScrollEnabled
319
+ keyboardShouldPersistTaps="handled"
320
+ >
321
+ {items.map((item) => {
322
+ const isItemDisabled = !!item.disabled;
323
+ const isHovered = hoveredItem === item.value;
324
+
325
+ return (
326
+ <Pressable
327
+ key={item.value}
328
+ onPress={() => handleSelect(item)}
329
+ onHoverIn={() =>
330
+ Platform.OS === 'web' && setHoveredItem(item.value)
331
+ }
332
+ onHoverOut={() =>
333
+ Platform.OS === 'web' && setHoveredItem(null)
334
+ }
335
+ style={({ pressed }) => [
336
+ styles.item,
337
+ isItemDisabled && styles.itemDisabled,
338
+ pressed && !isItemDisabled && styles.itemPressed,
339
+ Platform.OS === 'web' &&
340
+ isHovered &&
341
+ !isItemDisabled &&
342
+ styles.itemHovered,
343
+ ]}
344
+ >
345
+ {renderItem ? (
346
+ renderItem({
347
+ item,
348
+ isHighlighted: isHovered,
349
+ isDisabled: isItemDisabled,
350
+ searchQuery: currentInputValue,
351
+ })
352
+ ) : (
353
+ <View style={styles.itemContent}>
354
+ <Text
355
+ style={[
356
+ styles.itemText,
357
+ isItemDisabled && styles.itemTextDisabled,
358
+ ]}
359
+ numberOfLines={1}
360
+ >
361
+ {item.label}
362
+ </Text>
363
+ {item.description && (
364
+ <Text
365
+ style={[
366
+ styles.itemDescription,
367
+ isItemDisabled && styles.itemTextDisabled,
368
+ ]}
369
+ numberOfLines={1}
370
+ >
371
+ {item.description}
372
+ </Text>
373
+ )}
374
+ </View>
375
+ )}
376
+ </Pressable>
377
+ );
378
+ })}
379
+ </ScrollView>
380
+ )}
381
+ </View>
382
+ </>
383
+ ) : null;
384
+
385
+ // On web, portal the dropdown to document.body to escape parent overflow:hidden.
386
+ // On native, render inline (position: absolute works with RN's layout).
387
+ const portalledDropdown =
388
+ dropdownOverlay && Platform.OS === 'web' && createPortal && typeof document !== 'undefined'
389
+ ? createPortal(dropdownOverlay, document.body)
390
+ : dropdownOverlay;
391
+
392
+ return (
393
+ <View ref={ref} style={[styles.container, style]} {...props}>
394
+ {/* Label */}
395
+ {label && (
396
+ <Text style={[styles.label, isDisabled && styles.labelDisabled]}>
397
+ {label}
398
+ {isRequired && <Text style={styles.required}> *</Text>}
399
+ </Text>
400
+ )}
401
+
402
+ {/* Input Wrapper */}
403
+ <View
404
+ ref={wrapperRef}
405
+ style={[
406
+ styles.inputWrapper,
407
+ styles[variant],
408
+ styles[size],
409
+ isFocused && styles.inputFocused,
410
+ hasError && styles.inputError,
411
+ isDisabled && styles.inputDisabled,
412
+ ]}
413
+ >
414
+ {/* Left Element */}
415
+ {leftElement && <View style={styles.leftElement}>{leftElement}</View>}
416
+
417
+ {/* Text Input */}
418
+ <TextInput
419
+ ref={inputRef}
420
+ style={[styles.input, styles[`${size}Input` as keyof typeof styles]]}
421
+ value={currentInputValue}
422
+ onChangeText={handleInputChange}
423
+ placeholder={placeholder}
424
+ placeholderTextColor={colors.text.disabled}
425
+ editable={!isDisabled && !isReadOnly}
426
+ onFocus={handleFocus}
427
+ onBlur={handleBlur}
428
+ autoCapitalize="none"
429
+ autoCorrect={false}
430
+ {...textInputProps}
431
+ />
432
+
433
+ {/* Right Elements: custom + clear + chevron */}
434
+ <View style={styles.rightContainer}>
435
+ {rightElement && <View style={styles.rightElement}>{rightElement}</View>}
436
+
437
+ {showClear && (
438
+ <Pressable
439
+ onPress={handleClear}
440
+ style={({ hovered }: any) => [
441
+ styles.clearButton,
442
+ Platform.OS === 'web' && hovered && styles.clearButtonHovered,
443
+ ]}
444
+ hitSlop={8}
445
+ >
446
+ <Svg width={14} height={14} viewBox="0 0 24 24" fill="none">
447
+ <Path
448
+ d="M18 6L6 18M6 6l12 12"
449
+ stroke={colors.text.secondary}
450
+ strokeWidth={2}
451
+ strokeLinecap="round"
452
+ strokeLinejoin="round"
453
+ />
454
+ </Svg>
455
+ </Pressable>
456
+ )}
457
+ </View>
458
+ </View>
459
+
460
+ {/* Helper / Error Text */}
461
+ {(helperText || errorMessage) && (
462
+ <Text style={[styles.helperText, hasError && styles.errorText]}>
463
+ {errorMessage || helperText}
464
+ </Text>
465
+ )}
466
+
467
+ {/* Dropdown — portalled on web to escape parent overflow:hidden */}
468
+ {portalledDropdown}
469
+ </View>
470
+ );
471
+ }
472
+ );
473
+
474
+ Combobox.displayName = 'Combobox';
475
+
476
+ // =============================================================================
477
+ // Styles
478
+ // =============================================================================
479
+
480
+ const styles = StyleSheet.create({
481
+ container: {
482
+ width: '100%',
483
+ },
484
+
485
+ // Label
486
+ label: {
487
+ fontSize: typography.fontSize.componentLabel,
488
+ fontWeight: typography.fontWeight.semiBold,
489
+ color: colors.text.default,
490
+ marginBottom: spacing.base,
491
+ },
492
+ labelDisabled: {
493
+ color: colors.text.disabled,
494
+ },
495
+ required: {
496
+ color: colors.feedback.error.content,
497
+ },
498
+
499
+ // Input Wrapper
500
+ inputWrapper: {
501
+ flexDirection: 'row',
502
+ alignItems: 'center',
503
+ borderRadius: borderRadius.md,
504
+ },
505
+ outline: {
506
+ borderWidth: 1,
507
+ borderColor: colors.border.default,
508
+ backgroundColor: colors.background.default,
509
+ },
510
+ filled: {
511
+ backgroundColor: colors.background.secondary,
512
+ },
513
+ sm: {
514
+ height: 32,
515
+ paddingHorizontal: spacing['2x'],
516
+ },
517
+ md: {
518
+ height: 40,
519
+ paddingHorizontal: spacing['3x'],
520
+ },
521
+ lg: {
522
+ height: 48,
523
+ paddingHorizontal: spacing['4x'],
524
+ },
525
+ inputFocused: {
526
+ borderColor: colors.border.active,
527
+ },
528
+ inputError: {
529
+ borderColor: colors.border.danger,
530
+ },
531
+ inputDisabled: {
532
+ backgroundColor: colors.background.secondary,
533
+ borderColor: colors.border.disabled,
534
+ },
535
+
536
+ // Text Input
537
+ input: {
538
+ flex: 1,
539
+ color: colors.text.default,
540
+ padding: 0,
541
+ margin: 0,
542
+ ...Platform.select({
543
+ web: {
544
+ outlineStyle: 'none',
545
+ } as any,
546
+ }),
547
+ },
548
+ smInput: {
549
+ fontSize: typography.fontSize.small,
550
+ },
551
+ mdInput: {
552
+ fontSize: typography.fontSize.body,
553
+ },
554
+ lgInput: {
555
+ fontSize: typography.fontSize.body,
556
+ },
557
+
558
+ // Elements
559
+ leftElement: {
560
+ marginRight: spacing['2x'],
561
+ },
562
+ rightContainer: {
563
+ flexDirection: 'row',
564
+ alignItems: 'center',
565
+ gap: spacing.base,
566
+ },
567
+ rightElement: {
568
+ marginLeft: spacing.base,
569
+ },
570
+ clearButton: {
571
+ padding: spacing['0.5x'],
572
+ borderRadius: borderRadius.sm,
573
+ marginLeft: spacing.base,
574
+ },
575
+ clearButtonHovered: {
576
+ backgroundColor: colors.background.secondary,
577
+ },
578
+
579
+ // Helper / Error
580
+ helperText: {
581
+ fontSize: typography.fontSize.caption,
582
+ color: colors.text.secondary,
583
+ marginTop: spacing.base,
584
+ },
585
+ errorText: {
586
+ color: colors.feedback.error.content,
587
+ },
588
+
589
+ // Backdrop (replaces Modal overlay — keeps TextInput focusable)
590
+ backdrop: {
591
+ position: 'absolute',
592
+ top: 0,
593
+ left: 0,
594
+ right: 0,
595
+ bottom: 0,
596
+ zIndex: 9998,
597
+ backgroundColor: 'transparent',
598
+ },
599
+
600
+ // Dropdown
601
+ dropdown: {
602
+ position: 'absolute',
603
+ zIndex: 9999,
604
+ backgroundColor: colors.background.default,
605
+ borderRadius: borderRadius.md,
606
+ borderWidth: 1,
607
+ borderColor: colors.border.default,
608
+ minWidth: 200,
609
+ ...Platform.select({
610
+ ios: {
611
+ shadowColor: '#000',
612
+ shadowOffset: { width: 0, height: 4 },
613
+ shadowOpacity: 0.15,
614
+ shadowRadius: 12,
615
+ },
616
+ android: {
617
+ elevation: 8,
618
+ },
619
+ web: {
620
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
621
+ } as any,
622
+ }),
623
+ },
624
+
625
+ // Items list
626
+ itemsList: {
627
+ paddingVertical: spacing.base,
628
+ },
629
+
630
+ // State containers (loading / empty)
631
+ stateContainer: {
632
+ flexDirection: 'row',
633
+ alignItems: 'center',
634
+ justifyContent: 'center',
635
+ paddingVertical: spacing['4x'],
636
+ paddingHorizontal: spacing['3x'],
637
+ gap: spacing['2x'],
638
+ },
639
+ stateText: {
640
+ fontSize: typography.fontSize.body,
641
+ color: colors.text.secondary,
642
+ },
643
+
644
+ // Individual item
645
+ item: {
646
+ paddingVertical: spacing['2x'],
647
+ paddingHorizontal: spacing['3x'],
648
+ borderRadius: borderRadius.sm,
649
+ marginHorizontal: spacing['2x'],
650
+ },
651
+ itemHovered: {
652
+ backgroundColor: colors.background.hover,
653
+ },
654
+ itemPressed: {
655
+ backgroundColor: colors.background.secondary,
656
+ },
657
+ itemDisabled: {
658
+ opacity: 0.5,
659
+ },
660
+ itemContent: {
661
+ flexDirection: 'column',
662
+ gap: 2,
663
+ },
664
+ itemText: {
665
+ fontSize: typography.fontSize.body,
666
+ color: colors.text.default,
667
+ },
668
+ itemDescription: {
669
+ fontSize: typography.fontSize.caption,
670
+ color: colors.text.secondary,
671
+ },
672
+ itemTextDisabled: {
673
+ color: colors.text.disabled,
674
+ },
675
+ });
@@ -0,0 +1,6 @@
1
+ export {
2
+ Combobox,
3
+ type ComboboxProps,
4
+ type ComboboxItem,
5
+ type RenderComboboxItemProps
6
+ } from './Combobox';
@@ -224,6 +224,7 @@ export const DatePicker = forwardRef<View, DatePickerProps>(
224
224
  const [dropdownPosition, setDropdownPosition] = useState<LayoutRectangle | null>(null);
225
225
  const [viewDate, setViewDate] = useState(controlledValue || defaultValue || new Date());
226
226
  const pickerRef = useRef<View>(null);
227
+ const cachedPositionRef = useRef<LayoutRectangle | null>(null);
227
228
 
228
229
  const isControlled = controlledValue !== undefined;
229
230
  const selectedDate = isControlled ? controlledValue : internalValue;
@@ -302,17 +303,42 @@ export const DatePicker = forwardRef<View, DatePickerProps>(
302
303
  return Array.from({ length: 60 }, (_, i) => i);
303
304
  }, []);
304
305
 
305
- const handleOpen = useCallback(() => {
306
- if (isDisabled) return;
307
-
306
+ // Measure trigger position whenever layout changes so it's ready before user taps
307
+ const measureTrigger = useCallback(() => {
308
308
  pickerRef.current?.measureInWindow((x, y, width, height) => {
309
- setDropdownPosition({ x, y, width, height });
310
- setIsOpen(true);
311
- if (selectedDate) {
312
- setViewDate(selectedDate);
309
+ if (width > 0 && height > 0) {
310
+ const layout = { x, y, width, height };
311
+ cachedPositionRef.current = layout;
312
+ setDropdownPosition(layout);
313
313
  }
314
314
  });
315
- }, [isDisabled, selectedDate]);
315
+ }, []);
316
+
317
+ const handleTriggerLayout = useCallback(() => {
318
+ // Small delay to ensure native layout is committed
319
+ requestAnimationFrame(() => {
320
+ measureTrigger();
321
+ });
322
+ }, [measureTrigger]);
323
+
324
+ const handleOpen = useCallback(() => {
325
+ if (isDisabled) return;
326
+
327
+ if (selectedDate) {
328
+ setViewDate(selectedDate);
329
+ }
330
+
331
+ // Use cached position immediately (sync) so dropdown is positioned on first render
332
+ if (cachedPositionRef.current) {
333
+ setDropdownPosition(cachedPositionRef.current);
334
+ }
335
+
336
+ // Also re-measure for latest position (async, will update if position changed)
337
+ measureTrigger();
338
+
339
+ // Always open
340
+ setIsOpen(true);
341
+ }, [isDisabled, selectedDate, measureTrigger]);
316
342
 
317
343
  const handleClose = () => {
318
344
  setIsOpen(false);
@@ -430,7 +456,9 @@ export const DatePicker = forwardRef<View, DatePickerProps>(
430
456
 
431
457
  <Pressable
432
458
  ref={pickerRef}
459
+ collapsable={false}
433
460
  onPress={handleOpen}
461
+ onLayout={handleTriggerLayout}
434
462
  style={({ hovered }: any) => [
435
463
  styles.picker,
436
464
  styles[variant],
@@ -466,18 +494,27 @@ export const DatePicker = forwardRef<View, DatePickerProps>(
466
494
  animationType="fade"
467
495
  onRequestClose={handleClose}
468
496
  >
469
- <Pressable style={styles.modalOverlay} onPress={handleClose}>
497
+ <View style={styles.modalContainer}>
498
+ {/* Backdrop - sibling of dropdown so touches don't propagate */}
499
+ <Pressable style={StyleSheet.absoluteFillObject} onPress={handleClose} />
500
+
501
+ {/* Dropdown */}
470
502
  <View
471
503
  style={[
472
504
  styles.dropdown,
473
- dropdownPosition && {
474
- position: 'absolute',
475
- top: dropdownPosition.y + dropdownPosition.height + 4,
476
- left: dropdownPosition.x,
477
- minWidth: getDropdownMinWidth(),
478
- },
505
+ dropdownPosition
506
+ ? {
507
+ position: 'absolute',
508
+ top: dropdownPosition.y + dropdownPosition.height + 4,
509
+ left: dropdownPosition.x,
510
+ minWidth: getDropdownMinWidth(),
511
+ }
512
+ : {
513
+ // Centered fallback when position is unknown
514
+ alignSelf: 'center',
515
+ minWidth: getDropdownMinWidth(),
516
+ },
479
517
  ]}
480
- onStartShouldSetResponder={() => true}
481
518
  >
482
519
  {/* DateTime Mode - Side by Side Layout */}
483
520
  {mode === 'datetime' && (
@@ -898,7 +935,7 @@ export const DatePicker = forwardRef<View, DatePickerProps>(
898
935
  </View>
899
936
  )}
900
937
  </View>
901
- </Pressable>
938
+ </View>
902
939
  </Modal>
903
940
  </View>
904
941
  );
@@ -986,9 +1023,9 @@ const styles = StyleSheet.create({
986
1023
  errorText: {
987
1024
  color: colors.feedback.error.content,
988
1025
  },
989
- modalOverlay: {
1026
+ modalContainer: {
990
1027
  flex: 1,
991
- backgroundColor: 'transparent',
1028
+ justifyContent: 'center',
992
1029
  },
993
1030
  dropdown: {
994
1031
  backgroundColor: colors.background.default,
@@ -26,6 +26,7 @@ export * from './Switch';
26
26
  export * from './Slider';
27
27
  export * from './FormControl';
28
28
  export * from './DatePicker';
29
+ export * from './Combobox';
29
30
 
30
31
  // Feedback Components
31
32
  export * from './Alert';