@titan-design/react-ui 0.1.0 → 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.
- package/package.json +10 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
- package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
- package/src/components/custom/DateTime/DateTime.tsx +235 -0
- package/src/components/custom/DateTime/index.ts +2 -0
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
- package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
- package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
- package/src/components/custom/EmptyState/index.ts +2 -0
- package/src/components/custom/Metric/Metric.stories.tsx +133 -0
- package/src/components/custom/Metric/Metric.test.tsx +120 -0
- package/src/components/custom/Metric/Metric.tsx +92 -0
- package/src/components/custom/Metric/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
- package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
- package/src/components/custom/Sidebar/index.ts +18 -0
- package/src/components/custom/Table/Table.stories.tsx +481 -0
- package/src/components/custom/Table/Table.test.tsx +531 -0
- package/src/components/custom/Table/Table.tsx +696 -0
- package/src/components/custom/Table/index.ts +22 -0
- package/src/components/custom/Typography/Typography.stories.tsx +153 -0
- package/src/components/custom/Typography/Typography.test.tsx +107 -0
- package/src/components/custom/Typography/Typography.tsx +218 -0
- package/src/components/custom/Typography/index.ts +19 -0
- package/src/components/custom/index.ts +8 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
- package/src/components/custom/stepper/Stepper.test.tsx +230 -0
- package/src/components/custom/stepper/Stepper.tsx +250 -0
- package/src/components/custom/stepper/index.ts +10 -0
- package/src/components/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +154 -0
- package/src/components/ui/alert/Alert.test.tsx +202 -0
- package/src/components/ui/alert/Alert.tsx +170 -0
- package/src/components/ui/alert/index.ts +8 -0
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
- package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
- package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
- package/src/components/ui/autocomplete/index.ts +2 -0
- package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
- package/src/components/ui/avatar/Avatar.test.tsx +135 -0
- package/src/components/ui/avatar/Avatar.tsx +168 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/Badge.stories.tsx +100 -0
- package/src/components/ui/badge/Badge.test.tsx +86 -0
- package/src/components/ui/badge/Badge.tsx +105 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
- package/src/components/ui/breadcrumbs/index.ts +2 -0
- package/src/components/ui/button/Button.stories.tsx +216 -0
- package/src/components/ui/button/Button.test.tsx +132 -0
- package/src/components/ui/button/Button.tsx +242 -0
- package/src/components/ui/button/index.ts +10 -0
- package/src/components/ui/card/Card.stories.tsx +443 -0
- package/src/components/ui/card/Card.test.tsx +265 -0
- package/src/components/ui/card/Card.tsx +380 -0
- package/src/components/ui/card/index.ts +20 -0
- package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
- package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
- package/src/components/ui/checkbox/Checkbox.tsx +163 -0
- package/src/components/ui/checkbox/index.ts +2 -0
- package/src/components/ui/chip/Chip.stories.tsx +112 -0
- package/src/components/ui/chip/Chip.test.tsx +119 -0
- package/src/components/ui/chip/Chip.tsx +151 -0
- package/src/components/ui/chip/index.ts +2 -0
- package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
- package/src/components/ui/collapse/Collapse.test.tsx +298 -0
- package/src/components/ui/collapse/Collapse.tsx +275 -0
- package/src/components/ui/collapse/index.ts +18 -0
- package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
- package/src/components/ui/data-row/DataRow.test.tsx +78 -0
- package/src/components/ui/data-row/DataRow.tsx +29 -0
- package/src/components/ui/data-row/index.ts +2 -0
- package/src/components/ui/divider/Divider.stories.tsx +71 -0
- package/src/components/ui/divider/Divider.test.tsx +55 -0
- package/src/components/ui/divider/Divider.tsx +37 -0
- package/src/components/ui/divider/index.ts +2 -0
- package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
- package/src/components/ui/drawer/Drawer.test.tsx +142 -0
- package/src/components/ui/drawer/Drawer.tsx +228 -0
- package/src/components/ui/drawer/index.ts +9 -0
- package/src/components/ui/form-field/FormField.stories.tsx +260 -0
- package/src/components/ui/form-field/FormField.test.tsx +280 -0
- package/src/components/ui/form-field/FormField.tsx +308 -0
- package/src/components/ui/form-field/index.ts +2 -0
- package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
- package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
- package/src/components/ui/help-tip/HelpTip.tsx +229 -0
- package/src/components/ui/help-tip/index.ts +2 -0
- package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
- package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
- package/src/components/ui/icon-box/IconBox.tsx +57 -0
- package/src/components/ui/icon-box/index.ts +1 -0
- package/src/components/ui/index.ts +36 -0
- package/src/components/ui/input/Input.stories.tsx +192 -0
- package/src/components/ui/input/Input.test.tsx +86 -0
- package/src/components/ui/input/Input.tsx +273 -0
- package/src/components/ui/input/index.ts +2 -0
- package/src/components/ui/link/Link.stories.tsx +78 -0
- package/src/components/ui/link/Link.test.tsx +94 -0
- package/src/components/ui/link/Link.tsx +86 -0
- package/src/components/ui/link/index.ts +2 -0
- package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
- package/src/components/ui/list-item/ListItem.test.tsx +128 -0
- package/src/components/ui/list-item/ListItem.tsx +89 -0
- package/src/components/ui/list-item/index.ts +14 -0
- package/src/components/ui/menu/Menu.stories.tsx +180 -0
- package/src/components/ui/menu/Menu.test.tsx +353 -0
- package/src/components/ui/menu/Menu.tsx +222 -0
- package/src/components/ui/menu/index.ts +2 -0
- package/src/components/ui/modal/Modal.stories.tsx +350 -0
- package/src/components/ui/modal/Modal.test.tsx +159 -0
- package/src/components/ui/modal/Modal.tsx +254 -0
- package/src/components/ui/modal/index.ts +19 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +277 -0
- package/src/components/ui/popover/Popover.tsx +162 -0
- package/src/components/ui/popover/index.ts +2 -0
- package/src/components/ui/progress/Progress.stories.tsx +190 -0
- package/src/components/ui/progress/Progress.test.tsx +181 -0
- package/src/components/ui/progress/Progress.tsx +320 -0
- package/src/components/ui/progress/index.ts +9 -0
- package/src/components/ui/radio/Radio.stories.tsx +199 -0
- package/src/components/ui/radio/Radio.test.tsx +166 -0
- package/src/components/ui/radio/Radio.tsx +200 -0
- package/src/components/ui/radio/index.ts +2 -0
- package/src/components/ui/section/Section.stories.tsx +111 -0
- package/src/components/ui/section/Section.test.tsx +121 -0
- package/src/components/ui/section/Section.tsx +52 -0
- package/src/components/ui/section/index.ts +6 -0
- package/src/components/ui/select/Select.stories.tsx +286 -0
- package/src/components/ui/select/Select.test.tsx +180 -0
- package/src/components/ui/select/Select.tsx +236 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
- package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
- package/src/components/ui/skeleton/Skeleton.tsx +229 -0
- package/src/components/ui/skeleton/index.ts +16 -0
- package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
- package/src/components/ui/spinner/Spinner.test.tsx +67 -0
- package/src/components/ui/spinner/Spinner.tsx +72 -0
- package/src/components/ui/spinner/index.ts +2 -0
- package/src/components/ui/stack/Stack.stories.tsx +127 -0
- package/src/components/ui/stack/Stack.test.tsx +184 -0
- package/src/components/ui/stack/Stack.tsx +76 -0
- package/src/components/ui/stack/index.ts +2 -0
- package/src/components/ui/surface/Surface.stories.tsx +91 -0
- package/src/components/ui/surface/Surface.test.tsx +63 -0
- package/src/components/ui/surface/Surface.tsx +51 -0
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/switch/Switch.stories.tsx +111 -0
- package/src/components/ui/switch/Switch.test.tsx +112 -0
- package/src/components/ui/switch/Switch.tsx +103 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
- package/src/components/ui/tabs/Tabs.test.tsx +192 -0
- package/src/components/ui/tabs/Tabs.tsx +253 -0
- package/src/components/ui/tabs/index.ts +16 -0
- package/src/components/ui/toast/Toast.stories.tsx +255 -0
- package/src/components/ui/toast/Toast.test.tsx +207 -0
- package/src/components/ui/toast/Toast.tsx +327 -0
- package/src/components/ui/toast/index.ts +8 -0
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
- package/src/components/ui/toolbar-button/index.ts +7 -0
- package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
- package/src/components/ui/tooltip/Tooltip.tsx +143 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/test/setup.ts +19 -0
- package/src/theme/Colors.stories.tsx +258 -0
- package/src/theme/config.ts +193 -0
- package/src/theme/elevation.stories.tsx +346 -0
- package/src/theme/elevation.test.ts +27 -0
- package/src/theme/elevation.ts +567 -0
- package/src/theme/index.ts +39 -0
- package/src/theme/shadows.stories.tsx +523 -0
- package/src/theme/shadows.ts +439 -0
- package/src/theme/tokens/index.ts +2 -0
- package/src/theme/tokens/primitives.ts +354 -0
- package/src/theme/tokens/semantic.ts +381 -0
- package/src/types/jest-axe.d.ts +16 -0
- package/src/types/nativewind.d.ts +54 -0
- package/src/utils/cn.ts +14 -0
- package/src/utils/colors.ts +140 -0
- package/src/utils/form.ts +188 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/useTheme.ts +72 -0
|
@@ -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,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
|
+
}
|