@titan-design/react-ui 0.0.2 → 0.1.1

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 (206) hide show
  1. package/dist/{chunk-NA3EES23.mjs → chunk-MGW37MPO.mjs} +26 -4
  2. package/dist/chunk-MGW37MPO.mjs.map +1 -0
  3. package/dist/index.d.mts +115 -2
  4. package/dist/index.d.ts +115 -2
  5. package/dist/index.js +262 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +225 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/theme/index.d.mts +124 -109
  10. package/dist/theme/index.d.ts +124 -109
  11. package/dist/theme/index.js +24 -1
  12. package/dist/theme/index.js.map +1 -1
  13. package/dist/theme/index.mjs +1 -1
  14. package/package.json +12 -5
  15. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  16. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  17. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  18. package/src/components/custom/DateTime/index.ts +2 -0
  19. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  20. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  21. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  22. package/src/components/custom/EmptyState/index.ts +2 -0
  23. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  24. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  25. package/src/components/custom/Metric/Metric.tsx +92 -0
  26. package/src/components/custom/Metric/index.ts +2 -0
  27. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  28. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  29. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  30. package/src/components/custom/Sidebar/index.ts +18 -0
  31. package/src/components/custom/Table/Table.stories.tsx +481 -0
  32. package/src/components/custom/Table/Table.test.tsx +531 -0
  33. package/src/components/custom/Table/Table.tsx +696 -0
  34. package/src/components/custom/Table/index.ts +22 -0
  35. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  36. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  37. package/src/components/custom/Typography/Typography.tsx +218 -0
  38. package/src/components/custom/Typography/index.ts +19 -0
  39. package/src/components/custom/index.ts +8 -0
  40. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  41. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  42. package/src/components/custom/stepper/Stepper.tsx +250 -0
  43. package/src/components/custom/stepper/index.ts +10 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  46. package/src/components/ui/alert/Alert.test.tsx +202 -0
  47. package/src/components/ui/alert/Alert.tsx +170 -0
  48. package/src/components/ui/alert/index.ts +8 -0
  49. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  50. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  51. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  52. package/src/components/ui/autocomplete/index.ts +2 -0
  53. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  54. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  55. package/src/components/ui/avatar/Avatar.tsx +168 -0
  56. package/src/components/ui/avatar/index.ts +2 -0
  57. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  58. package/src/components/ui/badge/Badge.test.tsx +86 -0
  59. package/src/components/ui/badge/Badge.tsx +105 -0
  60. package/src/components/ui/badge/index.ts +2 -0
  61. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  62. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  63. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  64. package/src/components/ui/breadcrumbs/index.ts +2 -0
  65. package/src/components/ui/button/Button.stories.tsx +216 -0
  66. package/src/components/ui/button/Button.test.tsx +132 -0
  67. package/src/components/ui/button/Button.tsx +242 -0
  68. package/src/components/ui/button/index.ts +10 -0
  69. package/src/components/ui/card/Card.stories.tsx +443 -0
  70. package/src/components/ui/card/Card.test.tsx +265 -0
  71. package/src/components/ui/card/Card.tsx +380 -0
  72. package/src/components/ui/card/index.ts +20 -0
  73. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  74. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  75. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  76. package/src/components/ui/checkbox/index.ts +2 -0
  77. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  78. package/src/components/ui/chip/Chip.test.tsx +119 -0
  79. package/src/components/ui/chip/Chip.tsx +151 -0
  80. package/src/components/ui/chip/index.ts +2 -0
  81. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  82. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  83. package/src/components/ui/collapse/Collapse.tsx +275 -0
  84. package/src/components/ui/collapse/index.ts +18 -0
  85. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  86. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  87. package/src/components/ui/data-row/DataRow.tsx +29 -0
  88. package/src/components/ui/data-row/index.ts +2 -0
  89. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  90. package/src/components/ui/divider/Divider.test.tsx +55 -0
  91. package/src/components/ui/divider/Divider.tsx +37 -0
  92. package/src/components/ui/divider/index.ts +2 -0
  93. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  94. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  95. package/src/components/ui/drawer/Drawer.tsx +228 -0
  96. package/src/components/ui/drawer/index.ts +9 -0
  97. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  98. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  99. package/src/components/ui/form-field/FormField.tsx +308 -0
  100. package/src/components/ui/form-field/index.ts +2 -0
  101. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  102. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  103. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  104. package/src/components/ui/help-tip/index.ts +2 -0
  105. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  106. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  107. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  108. package/src/components/ui/icon-box/index.ts +1 -0
  109. package/src/components/ui/index.ts +36 -0
  110. package/src/components/ui/input/Input.stories.tsx +192 -0
  111. package/src/components/ui/input/Input.test.tsx +86 -0
  112. package/src/components/ui/input/Input.tsx +273 -0
  113. package/src/components/ui/input/index.ts +2 -0
  114. package/src/components/ui/link/Link.stories.tsx +78 -0
  115. package/src/components/ui/link/Link.test.tsx +94 -0
  116. package/src/components/ui/link/Link.tsx +86 -0
  117. package/src/components/ui/link/index.ts +2 -0
  118. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  119. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  120. package/src/components/ui/list-item/ListItem.tsx +89 -0
  121. package/src/components/ui/list-item/index.ts +14 -0
  122. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  123. package/src/components/ui/menu/Menu.test.tsx +353 -0
  124. package/src/components/ui/menu/Menu.tsx +222 -0
  125. package/src/components/ui/menu/index.ts +2 -0
  126. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  127. package/src/components/ui/modal/Modal.test.tsx +159 -0
  128. package/src/components/ui/modal/Modal.tsx +254 -0
  129. package/src/components/ui/modal/index.ts +19 -0
  130. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  131. package/src/components/ui/popover/Popover.test.tsx +277 -0
  132. package/src/components/ui/popover/Popover.tsx +162 -0
  133. package/src/components/ui/popover/index.ts +2 -0
  134. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  135. package/src/components/ui/progress/Progress.test.tsx +181 -0
  136. package/src/components/ui/progress/Progress.tsx +320 -0
  137. package/src/components/ui/progress/index.ts +9 -0
  138. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  139. package/src/components/ui/radio/Radio.test.tsx +166 -0
  140. package/src/components/ui/radio/Radio.tsx +200 -0
  141. package/src/components/ui/radio/index.ts +2 -0
  142. package/src/components/ui/section/Section.stories.tsx +111 -0
  143. package/src/components/ui/section/Section.test.tsx +121 -0
  144. package/src/components/ui/section/Section.tsx +52 -0
  145. package/src/components/ui/section/index.ts +6 -0
  146. package/src/components/ui/select/Select.stories.tsx +286 -0
  147. package/src/components/ui/select/Select.test.tsx +180 -0
  148. package/src/components/ui/select/Select.tsx +236 -0
  149. package/src/components/ui/select/index.ts +2 -0
  150. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  151. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  152. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  153. package/src/components/ui/skeleton/index.ts +16 -0
  154. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  155. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  156. package/src/components/ui/spinner/Spinner.tsx +72 -0
  157. package/src/components/ui/spinner/index.ts +2 -0
  158. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  159. package/src/components/ui/stack/Stack.test.tsx +184 -0
  160. package/src/components/ui/stack/Stack.tsx +76 -0
  161. package/src/components/ui/stack/index.ts +2 -0
  162. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  163. package/src/components/ui/surface/Surface.test.tsx +63 -0
  164. package/src/components/ui/surface/Surface.tsx +51 -0
  165. package/src/components/ui/surface/index.ts +1 -0
  166. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  167. package/src/components/ui/switch/Switch.test.tsx +112 -0
  168. package/src/components/ui/switch/Switch.tsx +103 -0
  169. package/src/components/ui/switch/index.ts +2 -0
  170. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  171. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  172. package/src/components/ui/tabs/Tabs.tsx +253 -0
  173. package/src/components/ui/tabs/index.ts +16 -0
  174. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  175. package/src/components/ui/toast/Toast.test.tsx +207 -0
  176. package/src/components/ui/toast/Toast.tsx +327 -0
  177. package/src/components/ui/toast/index.ts +8 -0
  178. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  179. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  180. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  181. package/src/components/ui/toolbar-button/index.ts +7 -0
  182. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  183. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  184. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  185. package/src/components/ui/tooltip/index.ts +2 -0
  186. package/src/index.ts +10 -0
  187. package/src/test/setup.ts +19 -0
  188. package/src/theme/Colors.stories.tsx +258 -0
  189. package/src/theme/config.ts +193 -0
  190. package/src/theme/elevation.stories.tsx +346 -0
  191. package/src/theme/elevation.test.ts +27 -0
  192. package/src/theme/elevation.ts +567 -0
  193. package/src/theme/index.ts +39 -0
  194. package/src/theme/shadows.stories.tsx +523 -0
  195. package/src/theme/shadows.ts +439 -0
  196. package/src/theme/tokens/index.ts +2 -0
  197. package/src/theme/tokens/primitives.ts +354 -0
  198. package/src/theme/tokens/semantic.ts +381 -0
  199. package/src/types/jest-axe.d.ts +16 -0
  200. package/src/types/nativewind.d.ts +54 -0
  201. package/src/utils/cn.ts +14 -0
  202. package/src/utils/colors.ts +140 -0
  203. package/src/utils/form.ts +188 -0
  204. package/src/utils/index.ts +29 -0
  205. package/src/utils/useTheme.ts +72 -0
  206. package/dist/chunk-NA3EES23.mjs.map +0 -1
@@ -0,0 +1,247 @@
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 { Autocomplete } from './Autocomplete'
5
+
6
+ const defaultOptions = [
7
+ { value: '1', label: 'Apple' },
8
+ { value: '2', label: 'Banana' },
9
+ { value: '3', label: 'Cherry' },
10
+ ]
11
+
12
+ const optionsWithDescriptions = [
13
+ { value: 'user1', label: 'John Doe', description: 'john@example.com' },
14
+ { value: 'user2', label: 'Jane Smith', description: 'jane@example.com' },
15
+ ]
16
+
17
+ function typeInInput(input: HTMLElement, text: string) {
18
+ fireEvent.focus(input)
19
+ fireEvent.change(input, { target: { value: text } })
20
+ }
21
+
22
+ describe('Autocomplete', () => {
23
+ it('renders with placeholder', () => {
24
+ render(<Autocomplete options={defaultOptions} placeholder="Search fruits..." />)
25
+ expect(screen.getByPlaceholderText('Search fruits...')).toBeInTheDocument()
26
+ })
27
+
28
+ it('renders default placeholder', () => {
29
+ render(<Autocomplete options={defaultOptions} />)
30
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
31
+ })
32
+
33
+ it('renders label', () => {
34
+ render(<Autocomplete options={defaultOptions} label="Fruit" />)
35
+ expect(screen.getByText('Fruit')).toBeInTheDocument()
36
+ })
37
+
38
+ it('renders required indicator', () => {
39
+ render(<Autocomplete options={defaultOptions} label="Fruit" isRequired />)
40
+ expect(screen.getByText('*')).toBeInTheDocument()
41
+ })
42
+
43
+ it('renders helper text', () => {
44
+ render(<Autocomplete options={defaultOptions} helperText="Pick your favorite" />)
45
+ expect(screen.getByText('Pick your favorite')).toBeInTheDocument()
46
+ })
47
+
48
+ it('renders error message instead of helper text', () => {
49
+ render(
50
+ <Autocomplete
51
+ options={defaultOptions}
52
+ helperText="Pick one"
53
+ errorMessage="This field is required"
54
+ />
55
+ )
56
+ expect(screen.getByText('This field is required')).toBeInTheDocument()
57
+ expect(screen.queryByText('Pick one')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('filters options based on input', () => {
61
+ render(<Autocomplete options={defaultOptions} />)
62
+ const input = screen.getByPlaceholderText('Search...')
63
+
64
+ typeInInput(input, 'App')
65
+ expect(screen.getByText('Apple')).toBeInTheDocument()
66
+ expect(screen.queryByText('Banana')).not.toBeInTheDocument()
67
+ })
68
+
69
+ it('shows no options text when no results match', () => {
70
+ render(<Autocomplete options={defaultOptions} />)
71
+ const input = screen.getByPlaceholderText('Search...')
72
+
73
+ typeInInput(input, 'Zzzz')
74
+ expect(screen.getByText('No options found')).toBeInTheDocument()
75
+ })
76
+
77
+ it('shows custom no options text', () => {
78
+ render(<Autocomplete options={defaultOptions} noOptionsText="Nothing here" />)
79
+ const input = screen.getByPlaceholderText('Search...')
80
+
81
+ typeInInput(input, 'Zzzz')
82
+ expect(screen.getByText('Nothing here')).toBeInTheDocument()
83
+ })
84
+
85
+ it('calls onChange when option is selected', () => {
86
+ const onChange = vi.fn()
87
+ render(<Autocomplete options={defaultOptions} onChange={onChange} />)
88
+ const input = screen.getByPlaceholderText('Search...')
89
+
90
+ typeInInput(input, 'Apple')
91
+ fireEvent.click(screen.getByText('Apple'))
92
+ expect(onChange).toHaveBeenCalledWith('1')
93
+ })
94
+
95
+ it('calls onInputChange when input text changes', () => {
96
+ const onInputChange = vi.fn()
97
+ render(<Autocomplete options={defaultOptions} onInputChange={onInputChange} />)
98
+ const input = screen.getByPlaceholderText('Search...')
99
+
100
+ typeInInput(input, 'Ban')
101
+ expect(onInputChange).toHaveBeenCalledWith('Ban')
102
+ })
103
+
104
+ it('shows clear button when value is selected', () => {
105
+ render(
106
+ <Autocomplete options={defaultOptions} value="1" isClearable />
107
+ )
108
+ expect(screen.getByLabelText('Clear selection')).toBeInTheDocument()
109
+ })
110
+
111
+ it('calls onChange with null when clear is pressed', () => {
112
+ const onChange = vi.fn()
113
+ render(
114
+ <Autocomplete options={defaultOptions} value="1" onChange={onChange} isClearable />
115
+ )
116
+
117
+ fireEvent.click(screen.getByLabelText('Clear selection'))
118
+ expect(onChange).toHaveBeenCalledWith(null)
119
+ })
120
+
121
+ it('does not show clear button when isClearable is false', () => {
122
+ render(
123
+ <Autocomplete options={defaultOptions} value="1" isClearable={false} />
124
+ )
125
+ expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument()
126
+ })
127
+
128
+ describe('minChars', () => {
129
+ it('does not show options until minimum characters are typed', () => {
130
+ render(<Autocomplete options={defaultOptions} minChars={3} />)
131
+ const input = screen.getByPlaceholderText('Search...')
132
+
133
+ typeInInput(input, 'Ap')
134
+ expect(screen.queryByText('Apple')).not.toBeInTheDocument()
135
+ expect(screen.getByText('Type at least 3 characters')).toBeInTheDocument()
136
+ })
137
+
138
+ it('shows options after minimum characters are typed', () => {
139
+ render(<Autocomplete options={defaultOptions} minChars={3} />)
140
+ const input = screen.getByPlaceholderText('Search...')
141
+
142
+ typeInInput(input, 'App')
143
+ expect(screen.getByText('Apple')).toBeInTheDocument()
144
+ })
145
+
146
+ it('shows custom minCharsText', () => {
147
+ render(
148
+ <Autocomplete
149
+ options={defaultOptions}
150
+ minChars={3}
151
+ minCharsText="Keep typing..."
152
+ />
153
+ )
154
+ const input = screen.getByPlaceholderText('Search...')
155
+
156
+ typeInInput(input, 'A')
157
+ expect(screen.getByText('Keep typing...')).toBeInTheDocument()
158
+ })
159
+ })
160
+
161
+ describe('loading state', () => {
162
+ it('shows loading indicator when isLoading', () => {
163
+ render(<Autocomplete options={defaultOptions} isLoading />)
164
+ const input = screen.getByPlaceholderText('Search...')
165
+ // The loading spinner icon is always visible, but loading text is in the dropdown
166
+ // which requires isOpen=true. Focus to open dropdown.
167
+ fireEvent.focus(input)
168
+ expect(screen.getByText('Loading...')).toBeInTheDocument()
169
+ })
170
+
171
+ it('shows custom loading text', () => {
172
+ render(<Autocomplete options={defaultOptions} isLoading loadingText="Fetching..." />)
173
+ const input = screen.getByPlaceholderText('Search...')
174
+ fireEvent.focus(input)
175
+ expect(screen.getByText('Fetching...')).toBeInTheDocument()
176
+ })
177
+ })
178
+
179
+ describe('disabled state', () => {
180
+ it('renders as disabled', () => {
181
+ render(<Autocomplete options={defaultOptions} isDisabled />)
182
+ const input = screen.getByPlaceholderText('Search...')
183
+ // react-native-web renders TextInput with editable=false as readonly
184
+ expect(input).toHaveAttribute('readonly')
185
+ })
186
+
187
+ it('does not show clear button when disabled', () => {
188
+ render(
189
+ <Autocomplete options={defaultOptions} value="1" isDisabled isClearable />
190
+ )
191
+ expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument()
192
+ })
193
+ })
194
+
195
+ describe('options with descriptions', () => {
196
+ it('renders option descriptions', () => {
197
+ render(<Autocomplete options={optionsWithDescriptions} />)
198
+ const input = screen.getByPlaceholderText('Search...')
199
+
200
+ typeInInput(input, 'John')
201
+ expect(screen.getByText('John Doe')).toBeInTheDocument()
202
+ expect(screen.getByText('john@example.com')).toBeInTheDocument()
203
+ })
204
+
205
+ it('filters by description text', () => {
206
+ render(<Autocomplete options={optionsWithDescriptions} />)
207
+ const input = screen.getByPlaceholderText('Search...')
208
+
209
+ typeInInput(input, 'jane@')
210
+ expect(screen.getByText('Jane Smith')).toBeInTheDocument()
211
+ expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
212
+ })
213
+ })
214
+
215
+ describe('custom filter function', () => {
216
+ it('uses custom filter function', () => {
217
+ const filterFn = vi.fn().mockReturnValue(true)
218
+ render(<Autocomplete options={defaultOptions} filterFn={filterFn} />)
219
+ const input = screen.getByPlaceholderText('Search...')
220
+
221
+ typeInInput(input, 'test')
222
+ expect(filterFn).toHaveBeenCalled()
223
+ })
224
+ })
225
+
226
+ describe('accessibility', () => {
227
+ it('has no accessibility violations', async () => {
228
+ const { container } = render(
229
+ <Autocomplete options={defaultOptions} label="Search" />
230
+ )
231
+ const results = await axe(container)
232
+ expect(results).toHaveNoViolations()
233
+ })
234
+
235
+ it('has accessible label on input', () => {
236
+ render(<Autocomplete options={defaultOptions} label="Search fruits" />)
237
+ expect(screen.getByLabelText('Search fruits')).toBeInTheDocument()
238
+ })
239
+
240
+ it('clear button has accessible label', () => {
241
+ render(
242
+ <Autocomplete options={defaultOptions} value="1" isClearable />
243
+ )
244
+ expect(screen.getByLabelText('Clear selection')).toBeInTheDocument()
245
+ })
246
+ })
247
+ })
@@ -0,0 +1,332 @@
1
+ import React, { useState, useCallback, useMemo } from 'react'
2
+ import { View, Text, TextInput, Pressable, ScrollView, type ViewProps, type TextInputProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export interface AutocompleteOption<T = string> {
6
+ value: T
7
+ label: string
8
+ description?: string
9
+ isDisabled?: boolean
10
+ }
11
+
12
+ export interface AutocompleteProps<T = string> extends ViewProps {
13
+ /** Available options */
14
+ options: AutocompleteOption<T>[]
15
+ /** Current selected value */
16
+ value?: T | null
17
+ /** Callback when value changes */
18
+ onChange?: (value: T | null) => void
19
+ /** Callback when input text changes (for async loading) */
20
+ onInputChange?: (inputValue: string) => void
21
+ /** Minimum characters before showing options */
22
+ minChars?: number
23
+ /** Placeholder text */
24
+ placeholder?: string
25
+ /** Label text */
26
+ label?: string
27
+ /** Helper text */
28
+ helperText?: string
29
+ /** Error message */
30
+ errorMessage?: string
31
+ /** Whether the field is required */
32
+ isRequired?: boolean
33
+ /** Whether the field is disabled */
34
+ isDisabled?: boolean
35
+ /** Whether the field is loading */
36
+ isLoading?: boolean
37
+ /** Whether to allow clearing the value */
38
+ isClearable?: boolean
39
+ /** Custom filter function */
40
+ filterFn?: (option: AutocompleteOption<T>, inputValue: string) => boolean
41
+ /** Custom render function for options */
42
+ renderOption?: (option: AutocompleteOption<T>, isHighlighted: boolean) => React.ReactNode
43
+ /** Text to show when no options match */
44
+ noOptionsText?: string
45
+ /** Text to show when loading */
46
+ loadingText?: string
47
+ /** Text to show when min chars not met */
48
+ minCharsText?: string
49
+ /** Additional className */
50
+ className?: string
51
+ /** Input props */
52
+ inputProps?: Partial<TextInputProps>
53
+ }
54
+
55
+ const defaultFilterFn = <T,>(option: AutocompleteOption<T>, inputValue: string) => {
56
+ const searchLower = inputValue.toLowerCase()
57
+ return (
58
+ option.label.toLowerCase().includes(searchLower) ||
59
+ (option.description?.toLowerCase().includes(searchLower) ?? false)
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Autocomplete component for searchable dropdown selection.
65
+ *
66
+ * @example
67
+ * // Basic usage
68
+ * <Autocomplete
69
+ * options={[
70
+ * { value: '1', label: 'Option 1' },
71
+ * { value: '2', label: 'Option 2' },
72
+ * ]}
73
+ * value={selected}
74
+ * onChange={setSelected}
75
+ * placeholder="Search..."
76
+ * />
77
+ *
78
+ * @example
79
+ * // With descriptions
80
+ * <Autocomplete
81
+ * options={[
82
+ * { value: 'user1', label: 'John Doe', description: 'john@example.com' },
83
+ * { value: 'user2', label: 'Jane Smith', description: 'jane@example.com' },
84
+ * ]}
85
+ * label="Select User"
86
+ * value={selectedUser}
87
+ * onChange={setSelectedUser}
88
+ * />
89
+ */
90
+ export function Autocomplete<T extends string = string>({
91
+ options,
92
+ value,
93
+ onChange,
94
+ onInputChange,
95
+ minChars = 0,
96
+ placeholder = 'Search...',
97
+ label,
98
+ helperText,
99
+ errorMessage,
100
+ isRequired = false,
101
+ isDisabled = false,
102
+ isLoading = false,
103
+ isClearable = true,
104
+ filterFn = defaultFilterFn,
105
+ renderOption,
106
+ noOptionsText = 'No options found',
107
+ loadingText = 'Loading...',
108
+ minCharsText,
109
+ className,
110
+ inputProps,
111
+ ...props
112
+ }: AutocompleteProps<T>) {
113
+ const [inputValue, setInputValue] = useState('')
114
+ const [isOpen, setIsOpen] = useState(false)
115
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
116
+
117
+ // Get the label for the selected value
118
+ const selectedOption = useMemo(
119
+ () => options.find((o) => o.value === value),
120
+ [options, value]
121
+ )
122
+
123
+ // Filter options based on input
124
+ const filteredOptions = useMemo(() => {
125
+ if (inputValue.length < minChars) return []
126
+ return options.filter((option) => filterFn(option, inputValue))
127
+ }, [options, inputValue, minChars, filterFn])
128
+
129
+ const handleInputChange = useCallback(
130
+ (text: string) => {
131
+ setInputValue(text)
132
+ setIsOpen(true)
133
+ setHighlightedIndex(-1)
134
+ onInputChange?.(text)
135
+ },
136
+ [onInputChange]
137
+ )
138
+
139
+ const handleSelectOption = useCallback(
140
+ (option: AutocompleteOption<T>) => {
141
+ if (option.isDisabled) return
142
+ onChange?.(option.value)
143
+ setInputValue(option.label)
144
+ setIsOpen(false)
145
+ },
146
+ [onChange]
147
+ )
148
+
149
+ const handleClear = useCallback(() => {
150
+ onChange?.(null)
151
+ setInputValue('')
152
+ setIsOpen(false)
153
+ }, [onChange])
154
+
155
+ const handleFocus = useCallback(() => {
156
+ setIsOpen(true)
157
+ // If there's a selected value, populate the input
158
+ if (selectedOption && !inputValue) {
159
+ setInputValue(selectedOption.label)
160
+ }
161
+ }, [selectedOption, inputValue])
162
+
163
+ const handleBlur = useCallback(() => {
164
+ // Delay to allow click on options
165
+ setTimeout(() => {
166
+ setIsOpen(false)
167
+ // Reset input to selected value if nothing new selected
168
+ if (selectedOption) {
169
+ setInputValue(selectedOption.label)
170
+ } else {
171
+ setInputValue('')
172
+ }
173
+ }, 200)
174
+ }, [selectedOption])
175
+
176
+ const isInvalid = !!errorMessage
177
+ const showMinCharsMessage = inputValue.length > 0 && inputValue.length < minChars
178
+ const showNoResults = !isLoading && inputValue.length >= minChars && filteredOptions.length === 0
179
+ const showOptions = isOpen && !showMinCharsMessage && filteredOptions.length > 0
180
+
181
+ return (
182
+ <View className={cn('w-full', className)} {...props}>
183
+ {/* Label */}
184
+ {label && (
185
+ <Text className="text-sm font-medium text-text-primary mb-1">
186
+ {label}
187
+ {isRequired && <Text className="text-status-error ml-0.5">*</Text>}
188
+ </Text>
189
+ )}
190
+
191
+ {/* Input Container */}
192
+ <View className="relative">
193
+ <View
194
+ className={cn(
195
+ 'flex-row items-center border rounded-md',
196
+ 'bg-surface-input',
197
+ isInvalid ? 'border-border-input-error' : 'border-border-input',
198
+ !isDisabled && !isInvalid && 'focus-within:border-border-input-focus',
199
+ isDisabled && 'opacity-50'
200
+ )}
201
+ >
202
+ <TextInput
203
+ value={inputValue}
204
+ onChangeText={handleInputChange}
205
+ onFocus={handleFocus}
206
+ onBlur={handleBlur}
207
+ placeholder={placeholder}
208
+ editable={!isDisabled}
209
+ className={cn(
210
+ 'flex-1 px-3 py-2 text-text-primary',
211
+ 'placeholder:text-text-tertiary'
212
+ )}
213
+ accessibilityLabel={label}
214
+ {...inputProps}
215
+ />
216
+
217
+ {/* Clear button */}
218
+ {isClearable && value && !isDisabled && (
219
+ <Pressable
220
+ onPress={handleClear}
221
+ className="p-2"
222
+ accessibilityLabel="Clear selection"
223
+ >
224
+ <Text className="text-text-tertiary">×</Text>
225
+ </Pressable>
226
+ )}
227
+
228
+ {/* Loading indicator */}
229
+ {isLoading && (
230
+ <View className="p-2">
231
+ <Text className="text-text-tertiary animate-spin">⟳</Text>
232
+ </View>
233
+ )}
234
+ </View>
235
+
236
+ {/* Dropdown */}
237
+ {isOpen && (
238
+ <View
239
+ className={cn(
240
+ 'absolute z-50 top-full left-0 right-0 mt-1',
241
+ 'bg-surface-elevated rounded-md shadow-lg border border-border-default',
242
+ 'max-h-60 overflow-hidden'
243
+ )}
244
+ >
245
+ <ScrollView className="py-1">
246
+ {/* Min chars message */}
247
+ {showMinCharsMessage && (
248
+ <Text className="px-3 py-2 text-sm text-text-tertiary">
249
+ {minCharsText || `Type at least ${minChars} characters`}
250
+ </Text>
251
+ )}
252
+
253
+ {/* Loading message */}
254
+ {isLoading && (
255
+ <Text className="px-3 py-2 text-sm text-text-tertiary">
256
+ {loadingText}
257
+ </Text>
258
+ )}
259
+
260
+ {/* No results */}
261
+ {showNoResults && (
262
+ <Text className="px-3 py-2 text-sm text-text-tertiary">
263
+ {noOptionsText}
264
+ </Text>
265
+ )}
266
+
267
+ {/* Options */}
268
+ {showOptions &&
269
+ filteredOptions.map((option, index) => {
270
+ const isHighlighted = index === highlightedIndex
271
+ const isSelected = option.value === value
272
+
273
+ if (renderOption) {
274
+ return (
275
+ <Pressable
276
+ key={String(option.value)}
277
+ onPress={() => handleSelectOption(option)}
278
+ disabled={option.isDisabled}
279
+ >
280
+ {renderOption(option, isHighlighted)}
281
+ </Pressable>
282
+ )
283
+ }
284
+
285
+ return (
286
+ <Pressable
287
+ key={String(option.value)}
288
+ onPress={() => handleSelectOption(option)}
289
+ disabled={option.isDisabled}
290
+ className={cn(
291
+ 'px-3 py-2',
292
+ isHighlighted && 'bg-interactive-hover',
293
+ isSelected && 'bg-interactive-selected',
294
+ option.isDisabled && 'opacity-50',
295
+ !option.isDisabled && 'web:hover:bg-interactive-hover'
296
+ )}
297
+ >
298
+ <Text
299
+ className={cn(
300
+ 'text-sm',
301
+ isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
302
+ )}
303
+ >
304
+ {option.label}
305
+ </Text>
306
+ {option.description && (
307
+ <Text className="text-xs text-text-tertiary mt-0.5">
308
+ {option.description}
309
+ </Text>
310
+ )}
311
+ </Pressable>
312
+ )
313
+ })}
314
+ </ScrollView>
315
+ </View>
316
+ )}
317
+ </View>
318
+
319
+ {/* Helper/Error text */}
320
+ {(helperText || errorMessage) && (
321
+ <Text
322
+ className={cn(
323
+ 'text-xs mt-1',
324
+ isInvalid ? 'text-status-error' : 'text-text-tertiary'
325
+ )}
326
+ >
327
+ {errorMessage || helperText}
328
+ </Text>
329
+ )}
330
+ </View>
331
+ )
332
+ }
@@ -0,0 +1,2 @@
1
+ export { Autocomplete } from './Autocomplete'
2
+ export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'
@@ -0,0 +1,105 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Avatar, AvatarBadge, AvatarGroup } from './Avatar'
4
+
5
+ const meta: Meta<typeof Avatar> = {
6
+ title: 'Components/Avatar',
7
+ component: Avatar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ size: {
11
+ control: 'select',
12
+ options: ['xs', 'sm', 'md', 'lg', 'xl', '2xl'],
13
+ description: 'Avatar size',
14
+ },
15
+ fallback: {
16
+ control: 'text',
17
+ description: 'Fallback text (usually initials)',
18
+ },
19
+ alt: {
20
+ control: 'text',
21
+ description: 'Alt text for accessibility',
22
+ },
23
+ },
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof Avatar>
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ fallback: 'JD',
32
+ size: 'md',
33
+ alt: 'John Doe',
34
+ },
35
+ }
36
+
37
+ export const AllSizes: Story = {
38
+ render: () => (
39
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
40
+ <Avatar size="xs" fallback="XS" />
41
+ <Avatar size="sm" fallback="SM" />
42
+ <Avatar size="md" fallback="MD" />
43
+ <Avatar size="lg" fallback="LG" />
44
+ <Avatar size="xl" fallback="XL" />
45
+ <Avatar size="2xl" fallback="2X" />
46
+ </View>
47
+ ),
48
+ }
49
+
50
+ export const WithImage: Story = {
51
+ args: {
52
+ source: { uri: 'https://i.pravatar.cc/150?u=avatar-story' },
53
+ alt: 'User avatar',
54
+ size: 'lg',
55
+ },
56
+ }
57
+
58
+ export const FallbackInitials: Story = {
59
+ render: () => (
60
+ <View style={{ flexDirection: 'row', gap: 8 }}>
61
+ <Avatar fallback="AB" alt="Alice Brown" />
62
+ <Avatar fallback="CD" alt="Charlie Davis" />
63
+ <Avatar fallback="EF" alt="Eve Foster" />
64
+ </View>
65
+ ),
66
+ }
67
+
68
+ export const WithBadge: Story = {
69
+ render: () => (
70
+ <View style={{ flexDirection: 'row', gap: 16 }}>
71
+ <View>
72
+ <Avatar fallback="JD" size="lg" />
73
+ <AvatarBadge color="success" />
74
+ </View>
75
+ <View>
76
+ <Avatar fallback="AB" size="lg" />
77
+ <AvatarBadge color="error" />
78
+ </View>
79
+ <View>
80
+ <Avatar fallback="CD" size="lg" />
81
+ <AvatarBadge color="warning" />
82
+ </View>
83
+ </View>
84
+ ),
85
+ }
86
+
87
+ export const Group: Story = {
88
+ render: () => (
89
+ <View style={{ gap: 16 }}>
90
+ <AvatarGroup size="md">
91
+ <Avatar fallback="A" />
92
+ <Avatar fallback="B" />
93
+ <Avatar fallback="C" />
94
+ <Avatar fallback="D" />
95
+ </AvatarGroup>
96
+ <AvatarGroup size="md" max={3}>
97
+ <Avatar fallback="A" />
98
+ <Avatar fallback="B" />
99
+ <Avatar fallback="C" />
100
+ <Avatar fallback="D" />
101
+ <Avatar fallback="E" />
102
+ </AvatarGroup>
103
+ </View>
104
+ ),
105
+ }