@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 +286 -3
- package/package.json +1 -1
- package/src/components/Combobox/Combobox.stories.tsx +195 -0
- package/src/components/Combobox/Combobox.tsx +675 -0
- package/src/components/Combobox/index.ts +6 -0
- package/src/components/DatePicker/DatePicker.tsx +56 -19
- package/src/components/index.ts +1 -0
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:
|
|
1892
|
-
*Documentation version: 1.
|
|
2174
|
+
*Last updated: February 2026*
|
|
2175
|
+
*Documentation version: 1.2.0*
|
|
1893
2176
|
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
1026
|
+
modalContainer: {
|
|
990
1027
|
flex: 1,
|
|
991
|
-
|
|
1028
|
+
justifyContent: 'center',
|
|
992
1029
|
},
|
|
993
1030
|
dropdown: {
|
|
994
1031
|
backgroundColor: colors.background.default,
|