@titan-design/react-ui 0.1.0 → 0.2.5

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 (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,286 @@
1
+ import React, { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text } from 'react-native'
4
+ import { Select, type SelectOption } from './Select'
5
+
6
+ const basicOptions: SelectOption[] = [
7
+ { value: 'apple', label: 'Apple' },
8
+ { value: 'banana', label: 'Banana' },
9
+ { value: 'cherry', label: 'Cherry' },
10
+ { value: 'dragonfruit', label: 'Dragon Fruit' },
11
+ { value: 'elderberry', label: 'Elderberry' },
12
+ ]
13
+
14
+ const meta: Meta<typeof Select> = {
15
+ title: 'Components/Select',
16
+ component: Select,
17
+ tags: ['autodocs'],
18
+ argTypes: {
19
+ placeholder: {
20
+ control: 'text',
21
+ description: 'Placeholder text when no value is selected',
22
+ },
23
+ isDisabled: {
24
+ control: 'boolean',
25
+ description: 'Whether the select is disabled',
26
+ },
27
+ isInvalid: {
28
+ control: 'boolean',
29
+ description: 'Whether the select has an error state',
30
+ },
31
+ isMulti: {
32
+ control: 'boolean',
33
+ description: 'Enable multi-select mode',
34
+ },
35
+ },
36
+ }
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof Select>
40
+
41
+ export const Default: Story = {
42
+ render: function Render() {
43
+ const [value, setValue] = useState<string | null>(null)
44
+ return (
45
+ <View style={{ width: 300 }}>
46
+ <Select
47
+ value={value}
48
+ onChange={setValue}
49
+ options={basicOptions}
50
+ placeholder="Select a fruit..."
51
+ />
52
+ </View>
53
+ )
54
+ },
55
+ }
56
+
57
+ export const WithPlaceholder: Story = {
58
+ render: function Render() {
59
+ const [value, setValue] = useState<string | null>(null)
60
+ return (
61
+ <View style={{ width: 300 }}>
62
+ <Select
63
+ value={value}
64
+ onChange={setValue}
65
+ options={basicOptions}
66
+ placeholder="Choose your favorite fruit..."
67
+ />
68
+ </View>
69
+ )
70
+ },
71
+ }
72
+
73
+ export const WithPreselectedValue: Story = {
74
+ render: function Render() {
75
+ const [value, setValue] = useState<string | null>('cherry')
76
+ return (
77
+ <View style={{ width: 300 }}>
78
+ <Select
79
+ value={value}
80
+ onChange={setValue}
81
+ options={basicOptions}
82
+ placeholder="Select a fruit..."
83
+ />
84
+ </View>
85
+ )
86
+ },
87
+ }
88
+
89
+ export const Disabled: Story = {
90
+ render: () => (
91
+ <View style={{ gap: 12, width: 300 }}>
92
+ <View style={{ gap: 4 }}>
93
+ <Text className="text-sm text-text-secondary">Disabled with no value</Text>
94
+ <Select
95
+ value={null}
96
+ onChange={() => {}}
97
+ options={basicOptions}
98
+ isDisabled
99
+ placeholder="Select a fruit..."
100
+ />
101
+ </View>
102
+ <View style={{ gap: 4 }}>
103
+ <Text className="text-sm text-text-secondary">Disabled with value</Text>
104
+ <Select
105
+ value="banana"
106
+ onChange={() => {}}
107
+ options={basicOptions}
108
+ isDisabled
109
+ />
110
+ </View>
111
+ </View>
112
+ ),
113
+ }
114
+
115
+ export const Invalid: Story = {
116
+ render: function Render() {
117
+ const [value, setValue] = useState<string | null>(null)
118
+ return (
119
+ <View style={{ width: 300, gap: 4 }}>
120
+ <Select
121
+ value={value}
122
+ onChange={setValue}
123
+ options={basicOptions}
124
+ isInvalid
125
+ placeholder="Select a fruit..."
126
+ />
127
+ <Text className="text-sm text-status-error">This field is required</Text>
128
+ </View>
129
+ )
130
+ },
131
+ }
132
+
133
+ export const MultiSelect: Story = {
134
+ render: function Render() {
135
+ const [values, setValues] = useState<string[]>([])
136
+ return (
137
+ <View style={{ width: 300, gap: 8 }}>
138
+ <Select
139
+ isMulti
140
+ values={values}
141
+ onChangeMulti={setValues}
142
+ options={basicOptions}
143
+ placeholder="Select fruits..."
144
+ />
145
+ <Text className="text-sm text-text-secondary">
146
+ Selected: {values.length > 0 ? values.join(', ') : 'none'}
147
+ </Text>
148
+ </View>
149
+ )
150
+ },
151
+ }
152
+
153
+ export const MultiSelectWithPreselected: Story = {
154
+ render: function Render() {
155
+ const [values, setValues] = useState<string[]>(['apple', 'cherry'])
156
+ return (
157
+ <View style={{ width: 300, gap: 8 }}>
158
+ <Select
159
+ isMulti
160
+ values={values}
161
+ onChangeMulti={setValues}
162
+ options={basicOptions}
163
+ placeholder="Select fruits..."
164
+ />
165
+ <Text className="text-sm text-text-secondary">
166
+ Selected: {values.length > 0 ? values.join(', ') : 'none'}
167
+ </Text>
168
+ </View>
169
+ )
170
+ },
171
+ }
172
+
173
+ export const ManyOptions: Story = {
174
+ render: function Render() {
175
+ const manyOptions: SelectOption[] = Array.from({ length: 50 }, (_, i) => ({
176
+ value: `option-${i + 1}`,
177
+ label: `Option ${i + 1}`,
178
+ }))
179
+ const [value, setValue] = useState<string | null>(null)
180
+ return (
181
+ <View style={{ width: 300 }}>
182
+ <Select
183
+ value={value}
184
+ onChange={setValue}
185
+ options={manyOptions}
186
+ placeholder="Select from many options..."
187
+ />
188
+ </View>
189
+ )
190
+ },
191
+ }
192
+
193
+ export const WithDisabledOptions: Story = {
194
+ render: function Render() {
195
+ const optionsWithDisabled: SelectOption[] = [
196
+ { value: 'apple', label: 'Apple' },
197
+ { value: 'banana', label: 'Banana', isDisabled: true },
198
+ { value: 'cherry', label: 'Cherry' },
199
+ { value: 'dragonfruit', label: 'Dragon Fruit', isDisabled: true },
200
+ { value: 'elderberry', label: 'Elderberry' },
201
+ ]
202
+ const [value, setValue] = useState<string | null>(null)
203
+ return (
204
+ <View style={{ width: 300 }}>
205
+ <Select
206
+ value={value}
207
+ onChange={setValue}
208
+ options={optionsWithDisabled}
209
+ placeholder="Some options are disabled..."
210
+ />
211
+ </View>
212
+ )
213
+ },
214
+ }
215
+
216
+ export const Controlled: Story = {
217
+ render: function Render() {
218
+ const [value, setValue] = useState<string | null>('apple')
219
+ return (
220
+ <View style={{ width: 300, gap: 12 }}>
221
+ <Select
222
+ value={value}
223
+ onChange={setValue}
224
+ options={basicOptions}
225
+ placeholder="Select a fruit..."
226
+ />
227
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
228
+ {basicOptions.map((option) => (
229
+ <View key={option.value} style={{ marginBottom: 4 }}>
230
+ <Text
231
+ className="text-sm text-brand-primary underline"
232
+ onPress={() => setValue(option.value)}
233
+ >
234
+ Set {option.label}
235
+ </Text>
236
+ </View>
237
+ ))}
238
+ <Text
239
+ className="text-sm text-status-error underline"
240
+ onPress={() => setValue(null)}
241
+ >
242
+ Clear
243
+ </Text>
244
+ </View>
245
+ <Text className="text-sm text-text-secondary">
246
+ Current value: {value ?? 'null'}
247
+ </Text>
248
+ </View>
249
+ )
250
+ },
251
+ }
252
+
253
+ export const InFormLayout: Story = {
254
+ render: function Render() {
255
+ const [fruit, setFruit] = useState<string | null>(null)
256
+ const [color, setColor] = useState<string | null>(null)
257
+ const colorOptions: SelectOption[] = [
258
+ { value: 'red', label: 'Red' },
259
+ { value: 'green', label: 'Green' },
260
+ { value: 'blue', label: 'Blue' },
261
+ { value: 'yellow', label: 'Yellow' },
262
+ ]
263
+ return (
264
+ <View style={{ width: 300, gap: 16 }}>
265
+ <View style={{ gap: 4 }}>
266
+ <Text className="text-sm font-medium text-text-primary">Favorite Fruit</Text>
267
+ <Select
268
+ value={fruit}
269
+ onChange={setFruit}
270
+ options={basicOptions}
271
+ placeholder="Select a fruit..."
272
+ />
273
+ </View>
274
+ <View style={{ gap: 4 }}>
275
+ <Text className="text-sm font-medium text-text-primary">Favorite Color</Text>
276
+ <Select
277
+ value={color}
278
+ onChange={setColor}
279
+ options={colorOptions}
280
+ placeholder="Select a color..."
281
+ />
282
+ </View>
283
+ </View>
284
+ )
285
+ },
286
+ }
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Select } from './Select'
5
+
6
+ const defaultOptions = [
7
+ { value: '1', label: 'Option 1' },
8
+ { value: '2', label: 'Option 2' },
9
+ { value: '3', label: 'Option 3' },
10
+ ]
11
+
12
+ const optionsWithDisabled = [
13
+ { value: '1', label: 'Option 1' },
14
+ { value: '2', label: 'Option 2', isDisabled: true },
15
+ { value: '3', label: 'Option 3' },
16
+ ]
17
+
18
+ describe('Select', () => {
19
+ it('renders with placeholder', () => {
20
+ render(<Select options={defaultOptions} placeholder="Pick one" />)
21
+ expect(screen.getByText('Pick one')).toBeInTheDocument()
22
+ })
23
+
24
+ it('renders default placeholder when none provided', () => {
25
+ render(<Select options={defaultOptions} />)
26
+ expect(screen.getByText('Select...')).toBeInTheDocument()
27
+ })
28
+
29
+ it('displays selected value label', () => {
30
+ render(<Select options={defaultOptions} value="2" />)
31
+ expect(screen.getByText('Option 2')).toBeInTheDocument()
32
+ })
33
+
34
+ it('opens dropdown when trigger is pressed', () => {
35
+ render(<Select options={defaultOptions} />)
36
+ const trigger = screen.getByRole('combobox')
37
+ fireEvent.click(trigger)
38
+ expect(screen.getByText('Option 1')).toBeInTheDocument()
39
+ expect(screen.getByText('Option 2')).toBeInTheDocument()
40
+ expect(screen.getByText('Option 3')).toBeInTheDocument()
41
+ })
42
+
43
+ it('calls onChange when an option is selected', () => {
44
+ const onChange = vi.fn()
45
+ render(<Select options={defaultOptions} onChange={onChange} />)
46
+
47
+ fireEvent.click(screen.getByRole('combobox'))
48
+ fireEvent.click(screen.getByText('Option 2'))
49
+ expect(onChange).toHaveBeenCalledWith('2')
50
+ })
51
+
52
+ it('closes dropdown after single-select selection', () => {
53
+ const onChange = vi.fn()
54
+ render(<Select options={defaultOptions} onChange={onChange} />)
55
+
56
+ fireEvent.click(screen.getByRole('combobox'))
57
+ fireEvent.click(screen.getByText('Option 1'))
58
+
59
+ // After selection, dropdown should close (options no longer visible beyond the selected display)
60
+ expect(onChange).toHaveBeenCalledWith('1')
61
+ })
62
+
63
+ it('does not open when disabled', () => {
64
+ render(<Select options={defaultOptions} isDisabled />)
65
+ const trigger = screen.getByRole('combobox')
66
+ fireEvent.click(trigger)
67
+ // Options should not appear
68
+ expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
69
+ })
70
+
71
+ it('shows clear button when value is selected', () => {
72
+ const onChange = vi.fn()
73
+ render(<Select options={defaultOptions} value="1" onChange={onChange} />)
74
+ // The clear button renders a "x" character
75
+ const clearButtons = screen.getAllByText('\u00d7')
76
+ expect(clearButtons.length).toBeGreaterThan(0)
77
+ })
78
+
79
+ describe('multi-select', () => {
80
+ it('renders with multi-select mode', () => {
81
+ render(<Select options={defaultOptions} isMulti values={[]} />)
82
+ expect(screen.getByText('Select...')).toBeInTheDocument()
83
+ })
84
+
85
+ it('displays count when multiple values selected', () => {
86
+ render(<Select options={defaultOptions} isMulti values={['1', '2']} />)
87
+ expect(screen.getByText('2 selected')).toBeInTheDocument()
88
+ })
89
+
90
+ it('displays single label when one value selected in multi mode', () => {
91
+ render(<Select options={defaultOptions} isMulti values={['1']} />)
92
+ expect(screen.getByText('Option 1')).toBeInTheDocument()
93
+ })
94
+
95
+ it('calls onChangeMulti when toggling options', () => {
96
+ const onChangeMulti = vi.fn()
97
+ render(
98
+ <Select
99
+ options={defaultOptions}
100
+ isMulti
101
+ values={[]}
102
+ onChangeMulti={onChangeMulti}
103
+ />
104
+ )
105
+
106
+ fireEvent.click(screen.getByRole('combobox'))
107
+ fireEvent.click(screen.getByText('Option 1'))
108
+ expect(onChangeMulti).toHaveBeenCalledWith(['1'])
109
+ })
110
+
111
+ it('removes value when already selected in multi mode', () => {
112
+ const onChangeMulti = vi.fn()
113
+ render(
114
+ <Select
115
+ options={defaultOptions}
116
+ isMulti
117
+ values={['1', '2']}
118
+ onChangeMulti={onChangeMulti}
119
+ />
120
+ )
121
+
122
+ fireEvent.click(screen.getByRole('combobox'))
123
+ fireEvent.click(screen.getByText('Option 1'))
124
+ expect(onChangeMulti).toHaveBeenCalledWith(['2'])
125
+ })
126
+ })
127
+
128
+ describe('disabled options', () => {
129
+ it('renders disabled options with reduced opacity', () => {
130
+ render(<Select options={optionsWithDisabled} />)
131
+ fireEvent.click(screen.getByRole('combobox'))
132
+ // All option labels should render
133
+ expect(screen.getByText('Option 2')).toBeInTheDocument()
134
+ })
135
+ })
136
+
137
+ describe('invalid state', () => {
138
+ it('renders with error styling when isInvalid', () => {
139
+ render(<Select options={defaultOptions} isInvalid />)
140
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
141
+ })
142
+ })
143
+
144
+ describe('filled variant', () => {
145
+ it('renders with filled variant without errors', () => {
146
+ render(<Select options={defaultOptions} variant="filled" />)
147
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
148
+ })
149
+
150
+ it('renders with default variant when omitted', () => {
151
+ render(<Select options={defaultOptions} />)
152
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
153
+ })
154
+
155
+ it('supports selection in filled variant', () => {
156
+ const onChange = vi.fn()
157
+ render(<Select options={defaultOptions} variant="filled" onChange={onChange} />)
158
+ fireEvent.click(screen.getByRole('combobox'))
159
+ fireEvent.click(screen.getByText('Option 2'))
160
+ expect(onChange).toHaveBeenCalledWith('2')
161
+ })
162
+ })
163
+
164
+ describe('accessibility', () => {
165
+ it('has no accessibility violations', async () => {
166
+ const { container } = render(
167
+ <Select options={defaultOptions} value="1" />
168
+ )
169
+ const results = await axe(container, {
170
+ rules: {
171
+ 'aria-input-field-name': { enabled: false },
172
+ 'aria-required-attr': { enabled: false },
173
+ },
174
+ })
175
+ expect(results).toHaveNoViolations()
176
+ })
177
+
178
+ it('has combobox role on trigger', () => {
179
+ render(<Select options={defaultOptions} />)
180
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
181
+ })
182
+
183
+ it('communicates expanded state', () => {
184
+ render(<Select options={defaultOptions} />)
185
+ const trigger = screen.getByRole('combobox')
186
+ // react-native-web does not map accessibilityState.expanded to aria-expanded
187
+ expect(trigger).toBeInTheDocument()
188
+
189
+ fireEvent.click(trigger)
190
+ // Verify dropdown opened by checking for options
191
+ expect(screen.getByText('Option 1')).toBeInTheDocument()
192
+ })
193
+
194
+ it('communicates disabled state', () => {
195
+ render(<Select options={defaultOptions} isDisabled />)
196
+ const trigger = screen.getByRole('combobox')
197
+ expect(trigger).toHaveAttribute('aria-disabled', 'true')
198
+ })
199
+ })
200
+ })