@instructure/ui-select 11.7.2-snapshot-47 → 11.7.2-snapshot-49
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/CHANGELOG.md +12 -2
- package/es/Select/v2/Group/index.js +47 -0
- package/es/Select/v2/Group/props.js +26 -0
- package/es/Select/v2/Option/index.js +51 -0
- package/es/Select/v2/Option/props.js +26 -0
- package/es/Select/v2/index.js +704 -0
- package/es/Select/v2/props.js +35 -0
- package/es/Select/v2/styles.js +41 -0
- package/es/exports/b.js +26 -0
- package/lib/Select/v2/Group/index.js +53 -0
- package/lib/Select/v2/Group/props.js +31 -0
- package/lib/Select/v2/Option/index.js +57 -0
- package/lib/Select/v2/Option/props.js +31 -0
- package/lib/Select/v2/index.js +714 -0
- package/lib/Select/v2/props.js +40 -0
- package/lib/Select/v2/styles.js +47 -0
- package/lib/exports/b.js +26 -0
- package/package.json +29 -29
- package/src/Select/v2/Group/index.tsx +52 -0
- package/src/Select/v2/Group/props.ts +48 -0
- package/src/Select/v2/Option/index.tsx +56 -0
- package/src/Select/v2/Option/props.ts +82 -0
- package/src/Select/v2/README.md +1342 -0
- package/src/Select/v2/index.tsx +897 -0
- package/src/Select/v2/props.ts +333 -0
- package/src/Select/v2/styles.ts +48 -0
- package/src/exports/b.ts +31 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/types/Select/v2/Group/index.d.ts +21 -0
- package/types/Select/v2/Group/index.d.ts.map +1 -0
- package/types/Select/v2/Group/props.d.ts +19 -0
- package/types/Select/v2/Group/props.d.ts.map +1 -0
- package/types/Select/v2/Option/index.d.ts +28 -0
- package/types/Select/v2/Option/index.d.ts.map +1 -0
- package/types/Select/v2/Option/props.d.ts +43 -0
- package/types/Select/v2/Option/props.d.ts.map +1 -0
- package/types/Select/v2/index.d.ts +138 -0
- package/types/Select/v2/index.d.ts.map +1 -0
- package/types/Select/v2/props.d.ts +206 -0
- package/types/Select/v2/props.d.ts.map +1 -0
- package/types/Select/v2/styles.d.ts +12 -0
- package/types/Select/v2/styles.d.ts.map +1 -0
- package/types/exports/b.d.ts +7 -0
- package/types/exports/b.d.ts.map +1 -0
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
---
|
|
2
|
+
describes: Select
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
`Select` is an accessible, custom styled combobox component for inputting a variety of data types.
|
|
6
|
+
|
|
7
|
+
- It behaves similar to [Popover](Popover) but provides additional semantic markup and focus behavior as a form input.
|
|
8
|
+
- It should not be used for navigation or as a list of actions/functions. (see [Menu](Menu)).
|
|
9
|
+
- It can behave like a `<select>` element or implement autocomplete behavior.
|
|
10
|
+
|
|
11
|
+
> Notes:
|
|
12
|
+
>
|
|
13
|
+
> - Before implementing Select, see if a [SimpleSelect](SimpleSelect) will suffice.
|
|
14
|
+
> - The `id` prop on options must be globally unique, it will be translated to an `id` prop in the DOM.
|
|
15
|
+
|
|
16
|
+
#### Managing state for a Select
|
|
17
|
+
|
|
18
|
+
`Select` is a controlled-only component. The consuming app or component must manage any state needed. A variety of request callbacks are provided as prompts for state updates. `onRequestShowOptions`, for example, is fired when `Select` thinks the `isShowingOptions` prop should be updated to `true`. Of course, the consumer can always choose how to react to these callbacks.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
---
|
|
22
|
+
type: example
|
|
23
|
+
---
|
|
24
|
+
const SingleSelectExample = ({ options }) => {
|
|
25
|
+
const [inputValue, setInputValue] = useState(options[0].label)
|
|
26
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
27
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
28
|
+
const [selectedOptionId, setSelectedOptionId] = useState(options[0].id)
|
|
29
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
30
|
+
const inputRef = useRef()
|
|
31
|
+
|
|
32
|
+
const focusInput = () => {
|
|
33
|
+
if (inputRef.current) {
|
|
34
|
+
inputRef.current.blur()
|
|
35
|
+
inputRef.current.focus()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const getOptionById = (queryId) => {
|
|
40
|
+
return options.find(({ id }) => id === queryId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const handleShowOptions = (event) => {
|
|
44
|
+
setIsShowingOptions(true)
|
|
45
|
+
if (inputValue || selectedOptionId || options.length === 0) return
|
|
46
|
+
|
|
47
|
+
if ('key' in event) {
|
|
48
|
+
switch (event.key) {
|
|
49
|
+
case 'ArrowDown':
|
|
50
|
+
return handleHighlightOption(event, { id: options[0].id })
|
|
51
|
+
case 'ArrowUp':
|
|
52
|
+
return handleHighlightOption(event, {
|
|
53
|
+
id: options[options.length - 1].id
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handleHideOptions = (event) => {
|
|
60
|
+
const option = getOptionById(selectedOptionId)?.label
|
|
61
|
+
setIsShowingOptions(false)
|
|
62
|
+
setHighlightedOptionId(null)
|
|
63
|
+
setSelectedOptionId(selectedOptionId ? option : '')
|
|
64
|
+
setAnnouncement('List collapsed.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleBlur = (event) => {
|
|
68
|
+
setHighlightedOptionId(null)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleHighlightOption = (event, { id }) => {
|
|
72
|
+
event.persist()
|
|
73
|
+
const optionsAvailable = `${options.length} options available.`
|
|
74
|
+
const nowOpen = !isShowingOptions
|
|
75
|
+
? `List expanded. ${optionsAvailable}`
|
|
76
|
+
: ''
|
|
77
|
+
const option = getOptionById(id)?.label
|
|
78
|
+
setHighlightedOptionId(id)
|
|
79
|
+
setInputValue(inputValue)
|
|
80
|
+
setAnnouncement(`${option} ${nowOpen}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handleSelectOption = (event, { id }) => {
|
|
84
|
+
focusInput()
|
|
85
|
+
const option = getOptionById(id)?.label
|
|
86
|
+
setSelectedOptionId(id)
|
|
87
|
+
setInputValue(option)
|
|
88
|
+
setIsShowingOptions(false)
|
|
89
|
+
setAnnouncement(`"${option}" selected. List collapsed.`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div>
|
|
94
|
+
<Select
|
|
95
|
+
renderLabel="Single Select"
|
|
96
|
+
assistiveText="Use arrow keys to navigate options."
|
|
97
|
+
inputValue={inputValue}
|
|
98
|
+
isShowingOptions={isShowingOptions}
|
|
99
|
+
onBlur={handleBlur}
|
|
100
|
+
onRequestShowOptions={handleShowOptions}
|
|
101
|
+
onRequestHideOptions={handleHideOptions}
|
|
102
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
103
|
+
onRequestSelectOption={handleSelectOption}
|
|
104
|
+
inputRef={(el) => {
|
|
105
|
+
inputRef.current = el
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{options.map((option) => {
|
|
109
|
+
return (
|
|
110
|
+
<Select.Option
|
|
111
|
+
id={option.id}
|
|
112
|
+
key={option.id}
|
|
113
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
114
|
+
isSelected={option.id === selectedOptionId}
|
|
115
|
+
>
|
|
116
|
+
{option.label}
|
|
117
|
+
</Select.Option>
|
|
118
|
+
)
|
|
119
|
+
})}
|
|
120
|
+
</Select>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
render(
|
|
125
|
+
<View>
|
|
126
|
+
<SingleSelectExample
|
|
127
|
+
options={[
|
|
128
|
+
{ id: 'opt1', label: 'Alaska' },
|
|
129
|
+
{ id: 'opt2', label: 'American Samoa' },
|
|
130
|
+
{ id: 'opt3', label: 'Arizona' },
|
|
131
|
+
{ id: 'opt4', label: 'Arkansas' },
|
|
132
|
+
{ id: 'opt5', label: 'California' },
|
|
133
|
+
{ id: 'opt6', label: 'Colorado' },
|
|
134
|
+
{ id: 'opt7', label: 'Connecticut' },
|
|
135
|
+
{ id: 'opt8', label: 'Delaware' },
|
|
136
|
+
{ id: 'opt9', label: 'District Of Columbia' },
|
|
137
|
+
{ id: 'opt10', label: 'Federated States Of Micronesia' },
|
|
138
|
+
{ id: 'opt11', label: 'Florida' },
|
|
139
|
+
{ id: 'opt12', label: 'Georgia (unavailable)' },
|
|
140
|
+
{ id: 'opt13', label: 'Guam' },
|
|
141
|
+
{ id: 'opt14', label: 'Hawaii' },
|
|
142
|
+
{ id: 'opt15', label: 'Idaho' },
|
|
143
|
+
{ id: 'opt16', label: 'Illinois' }
|
|
144
|
+
]}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Providing autocomplete behavior
|
|
151
|
+
|
|
152
|
+
It's best practice to always provide autocomplete functionality to help users make a selection. The example below demonstrates one method of filtering options based on user input, but this logic should be customized to what works best for the application.
|
|
153
|
+
|
|
154
|
+
> Note: Select makes some conditional assumptions about keyboard behavior. For example, if the list is NOT showing, up/down arrow keys and the space key, will show the list. Otherwise, the arrows will navigate options and the space key will type a space character.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
---
|
|
158
|
+
type: example
|
|
159
|
+
---
|
|
160
|
+
const AutocompleteExample = ({ options }) => {
|
|
161
|
+
const [inputValue, setInputValue] = useState('')
|
|
162
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
163
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
164
|
+
const [selectedOptionId, setSelectedOptionId] = useState(null)
|
|
165
|
+
const [filteredOptions, setFilteredOptions] = useState(options)
|
|
166
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
167
|
+
const inputRef = useRef()
|
|
168
|
+
|
|
169
|
+
const focusInput = () => {
|
|
170
|
+
if (inputRef.current) {
|
|
171
|
+
inputRef.current.blur()
|
|
172
|
+
inputRef.current.focus()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const getOptionById = (queryId) => {
|
|
177
|
+
return options.find(({ id }) => id === queryId)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const getOptionsChangedMessage = (newOptions) => {
|
|
181
|
+
let message =
|
|
182
|
+
newOptions.length !== filteredOptions.length
|
|
183
|
+
? `${newOptions.length} options available.` // options changed, announce new total
|
|
184
|
+
: null // options haven't changed, don't announce
|
|
185
|
+
if (message && newOptions.length > 0) {
|
|
186
|
+
// options still available
|
|
187
|
+
if (highlightedOptionId !== newOptions[0].id) {
|
|
188
|
+
// highlighted option hasn't been announced
|
|
189
|
+
const option = getOptionById(newOptions[0].id).label
|
|
190
|
+
message = `${option}. ${message}`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return message
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const filterOptions = (value) => {
|
|
197
|
+
return options.filter((option) =>
|
|
198
|
+
option.label.toLowerCase().startsWith(value.toLowerCase())
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const matchValue = () => {
|
|
203
|
+
// an option matching user input exists
|
|
204
|
+
if (filteredOptions.length === 1) {
|
|
205
|
+
const onlyOption = filteredOptions[0]
|
|
206
|
+
// automatically select the matching option
|
|
207
|
+
if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) {
|
|
208
|
+
setInputValue(onlyOption.label)
|
|
209
|
+
setSelectedOptionId(onlyOption.id)
|
|
210
|
+
setFilteredOptions(filterOptions(''))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// allow user to return to empty input and no selection
|
|
214
|
+
else if (inputValue.length === 0) {
|
|
215
|
+
setSelectedOptionId(null)
|
|
216
|
+
}
|
|
217
|
+
// no match found, return selected option label to input
|
|
218
|
+
else if (selectedOptionId) {
|
|
219
|
+
const selectedOption = getOptionById(selectedOptionId)
|
|
220
|
+
setInputValue(selectedOption.label)
|
|
221
|
+
}
|
|
222
|
+
// input value is from highlighted option, not user input
|
|
223
|
+
// clear input, reset options
|
|
224
|
+
else if (highlightedOptionId) {
|
|
225
|
+
if (inputValue === getOptionById(highlightedOptionId).label) {
|
|
226
|
+
setInputValue('')
|
|
227
|
+
setFilteredOptions(filterOptions(''))
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const handleShowOptions = (event) => {
|
|
233
|
+
setIsShowingOptions(true)
|
|
234
|
+
setAnnouncement(
|
|
235
|
+
`List expanded. ${filteredOptions.length} options available.`
|
|
236
|
+
)
|
|
237
|
+
if (inputValue || selectedOptionId || options.length === 0) return
|
|
238
|
+
|
|
239
|
+
if ('key' in event) {
|
|
240
|
+
switch (event.key) {
|
|
241
|
+
case 'ArrowDown':
|
|
242
|
+
return handleHighlightOption(event, { id: options[0].id })
|
|
243
|
+
case 'ArrowUp':
|
|
244
|
+
return handleHighlightOption(event, {
|
|
245
|
+
id: options[options.length - 1].id
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const handleHideOptions = (event) => {
|
|
252
|
+
setIsShowingOptions(false)
|
|
253
|
+
setHighlightedOptionId(false)
|
|
254
|
+
setAnnouncement('List collapsed.')
|
|
255
|
+
matchValue()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const handleBlur = (event) => {
|
|
259
|
+
setHighlightedOptionId(null)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const handleHighlightOption = (event, { id }) => {
|
|
263
|
+
event.persist()
|
|
264
|
+
const option = getOptionById(id)
|
|
265
|
+
if (!option) return // prevent highlighting of empty option
|
|
266
|
+
setHighlightedOptionId(id)
|
|
267
|
+
setInputValue(inputValue)
|
|
268
|
+
setAnnouncement(option.label)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const handleSelectOption = (event, { id }) => {
|
|
272
|
+
const option = getOptionById(id)
|
|
273
|
+
if (!option) return // prevent selecting of empty option
|
|
274
|
+
focusInput()
|
|
275
|
+
setSelectedOptionId(id)
|
|
276
|
+
setInputValue(option.label)
|
|
277
|
+
setIsShowingOptions(false)
|
|
278
|
+
setFilteredOptions(options)
|
|
279
|
+
setAnnouncement(`${option.label} selected. List collapsed.`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const handleInputChange = (event) => {
|
|
283
|
+
const value = event.target.value
|
|
284
|
+
const newOptions = filterOptions(value)
|
|
285
|
+
setInputValue(value)
|
|
286
|
+
setFilteredOptions(newOptions)
|
|
287
|
+
setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null)
|
|
288
|
+
setIsShowingOptions(true)
|
|
289
|
+
setSelectedOptionId(value === '' ? null : selectedOptionId)
|
|
290
|
+
setAnnouncement(getOptionsChangedMessage(newOptions))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div>
|
|
295
|
+
<Select
|
|
296
|
+
renderLabel="Autocomplete"
|
|
297
|
+
assistiveText="Type or use arrow keys to navigate options."
|
|
298
|
+
placeholder="Start typing to search..."
|
|
299
|
+
inputValue={inputValue}
|
|
300
|
+
isShowingOptions={isShowingOptions}
|
|
301
|
+
onBlur={handleBlur}
|
|
302
|
+
onInputChange={handleInputChange}
|
|
303
|
+
onRequestShowOptions={handleShowOptions}
|
|
304
|
+
onRequestHideOptions={handleHideOptions}
|
|
305
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
306
|
+
onRequestSelectOption={handleSelectOption}
|
|
307
|
+
renderBeforeInput={<User2InstUIIcon inline={false} />}
|
|
308
|
+
renderAfterInput={<SearchInstUIIcon inline={false} />}
|
|
309
|
+
inputRef={(el) => {
|
|
310
|
+
inputRef.current = el
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{filteredOptions.length > 0 ? (
|
|
314
|
+
filteredOptions.map((option) => {
|
|
315
|
+
return (
|
|
316
|
+
<Select.Option
|
|
317
|
+
id={option.id}
|
|
318
|
+
key={option.id}
|
|
319
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
320
|
+
isSelected={option.id === selectedOptionId}
|
|
321
|
+
isDisabled={option.disabled}
|
|
322
|
+
renderBeforeLabel={<User2InstUIIcon />}
|
|
323
|
+
>
|
|
324
|
+
{!option.disabled
|
|
325
|
+
? option.label
|
|
326
|
+
: `${option.label} (unavailable)`}
|
|
327
|
+
</Select.Option>
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
) : (
|
|
331
|
+
<Select.Option id="empty-option" key="empty-option">
|
|
332
|
+
---
|
|
333
|
+
</Select.Option>
|
|
334
|
+
)}
|
|
335
|
+
</Select>
|
|
336
|
+
</div>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
render(
|
|
341
|
+
<View>
|
|
342
|
+
<AutocompleteExample
|
|
343
|
+
options={[
|
|
344
|
+
{ id: 'opt0', label: 'Aaron Aaronson' },
|
|
345
|
+
{ id: 'opt1', label: 'Amber Murphy' },
|
|
346
|
+
{ id: 'opt2', label: 'Andrew Miller' },
|
|
347
|
+
{ id: 'opt3', label: 'Barbara Ward' },
|
|
348
|
+
{ id: 'opt4', label: 'Byron Cranston', disabled: true },
|
|
349
|
+
{ id: 'opt5', label: 'Dennis Reynolds' },
|
|
350
|
+
{ id: 'opt6', label: 'Dee Reynolds' },
|
|
351
|
+
{ id: 'opt7', label: 'Ezra Betterthan' },
|
|
352
|
+
{ id: 'opt8', label: 'Jeff Spicoli' },
|
|
353
|
+
{ id: 'opt9', label: 'Joseph Smith' },
|
|
354
|
+
{ id: 'opt10', label: 'Jasmine Diaz' },
|
|
355
|
+
{ id: 'opt11', label: 'Martin Harris' },
|
|
356
|
+
{ id: 'opt12', label: 'Michael Morgan', disabled: true },
|
|
357
|
+
{ id: 'opt13', label: 'Michelle Rodriguez' },
|
|
358
|
+
{ id: 'opt14', label: 'Ziggy Stardust' }
|
|
359
|
+
]}
|
|
360
|
+
/>
|
|
361
|
+
</View>
|
|
362
|
+
)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
#### Highlighting and selecting options
|
|
366
|
+
|
|
367
|
+
To mark an option as "highlighted", use the option's `isHighlighted` prop. Note that only one highlighted option is permitted. Similarly, use `isSelected` to mark an option or multiple options as "selected". When allowing multiple selections, it's best to render a [Tag](Tag) with [AccessibleContent](AccessibleContent) for each selected option via the `renderBeforeInput` prop.
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
---
|
|
371
|
+
type: example
|
|
372
|
+
---
|
|
373
|
+
const MultipleSelectExample = ({ options }) => {
|
|
374
|
+
const [inputValue, setInputValue] = useState('')
|
|
375
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
376
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
377
|
+
const [selectedOptionId, setSelectedOptionId] = useState(['opt1', 'opt6'])
|
|
378
|
+
const [filteredOptions, setFilteredOptions] = useState(options)
|
|
379
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
380
|
+
const inputRef = useRef()
|
|
381
|
+
|
|
382
|
+
const focusInput = () => {
|
|
383
|
+
if (inputRef.current) {
|
|
384
|
+
inputRef.current.blur()
|
|
385
|
+
inputRef.current.focus()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const getOptionById = (queryId) => {
|
|
390
|
+
return options.find(({ id }) => id === queryId)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const getOptionsChangedMessage = (newOptions) => {
|
|
394
|
+
let message =
|
|
395
|
+
newOptions.length !== filteredOptions.length
|
|
396
|
+
? `${newOptions.length} options available.` // options changed, announce new total
|
|
397
|
+
: null // options haven't changed, don't announce
|
|
398
|
+
if (message && newOptions.length > 0) {
|
|
399
|
+
// options still available
|
|
400
|
+
if (highlightedOptionId !== newOptions[0].id) {
|
|
401
|
+
// highlighted option hasn't been announced
|
|
402
|
+
const option = getOptionById(newOptions[0].id).label
|
|
403
|
+
message = `${option}. ${message}`
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return message
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const filterOptions = (value) => {
|
|
410
|
+
return options.filter((option) =>
|
|
411
|
+
option.label.toLowerCase().startsWith(value.toLowerCase())
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const matchValue = () => {
|
|
416
|
+
// an option matching user input exists
|
|
417
|
+
if (filteredOptions.length === 1) {
|
|
418
|
+
const onlyOption = filteredOptions[0]
|
|
419
|
+
// automatically select the matching option
|
|
420
|
+
if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) {
|
|
421
|
+
setInputValue('')
|
|
422
|
+
setSelectedOptionId([...selectedOptionId, onlyOption.id])
|
|
423
|
+
setFilteredOptions(filterOptions(''))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// input value is from highlighted option, not user input
|
|
427
|
+
// clear input, reset options
|
|
428
|
+
else if (highlightedOptionId) {
|
|
429
|
+
if (inputValue === getOptionById(highlightedOptionId).label) {
|
|
430
|
+
setInputValue('')
|
|
431
|
+
setFilteredOptions(filterOptions(''))
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const handleShowOptions = (event) => {
|
|
437
|
+
setIsShowingOptions(true)
|
|
438
|
+
|
|
439
|
+
if (inputValue || options.length === 0) return
|
|
440
|
+
|
|
441
|
+
if ('key' in event) {
|
|
442
|
+
switch (event.key) {
|
|
443
|
+
case 'ArrowDown':
|
|
444
|
+
return handleHighlightOption(event, {
|
|
445
|
+
id: options.find((option) => !selectedOptionId.includes(option.id))
|
|
446
|
+
.id
|
|
447
|
+
})
|
|
448
|
+
case 'ArrowUp':
|
|
449
|
+
// Highlight last non-selected option
|
|
450
|
+
return handleHighlightOption(event, {
|
|
451
|
+
id: options[
|
|
452
|
+
options.findLastIndex(
|
|
453
|
+
(option) => !selectedOptionId.includes(option.id)
|
|
454
|
+
)
|
|
455
|
+
].id
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const handleHideOptions = (event) => {
|
|
462
|
+
setIsShowingOptions(false)
|
|
463
|
+
matchValue()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const handleBlur = (event) => {
|
|
467
|
+
setHighlightedOptionId(null)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const handleHighlightOption = (event, { id }) => {
|
|
471
|
+
event.persist()
|
|
472
|
+
const option = getOptionById(id)
|
|
473
|
+
if (!option) return // prevent highlighting empty option
|
|
474
|
+
setHighlightedOptionId(id)
|
|
475
|
+
setInputValue(inputValue)
|
|
476
|
+
setAnnouncement(option.label)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const handleSelectOption = (event, { id }) => {
|
|
480
|
+
const option = getOptionById(id)
|
|
481
|
+
if (!option) return // prevent selecting of empty option
|
|
482
|
+
focusInput()
|
|
483
|
+
setSelectedOptionId([...selectedOptionId, id])
|
|
484
|
+
setHighlightedOptionId(null)
|
|
485
|
+
setFilteredOptions(filterOptions(''))
|
|
486
|
+
setInputValue('')
|
|
487
|
+
setIsShowingOptions(false)
|
|
488
|
+
setAnnouncement(`${option.label} selected. List collapsed.`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const handleInputChange = (event) => {
|
|
492
|
+
const value = event.target.value
|
|
493
|
+
const newOptions = filterOptions(value)
|
|
494
|
+
setInputValue(value)
|
|
495
|
+
setFilteredOptions(newOptions)
|
|
496
|
+
setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null)
|
|
497
|
+
setIsShowingOptions(true)
|
|
498
|
+
setAnnouncement(getOptionsChangedMessage(newOptions))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const handleKeyDown = (event) => {
|
|
502
|
+
if ('keyCode' in event && event.keyCode === 8) {
|
|
503
|
+
// when backspace key is pressed
|
|
504
|
+
if (inputValue === '' && selectedOptionId.length > 0) {
|
|
505
|
+
// remove last selected option, if input has no entered text
|
|
506
|
+
setHighlightedOptionId(null)
|
|
507
|
+
setSelectedOptionId(selectedOptionId.slice(0, -1))
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// remove a selected option tag
|
|
513
|
+
const dismissTag = (e, tag) => {
|
|
514
|
+
// prevent closing of list
|
|
515
|
+
e.stopPropagation()
|
|
516
|
+
e.preventDefault()
|
|
517
|
+
|
|
518
|
+
const newSelection = selectedOptionId.filter((id) => id !== tag)
|
|
519
|
+
|
|
520
|
+
setSelectedOptionId(newSelection)
|
|
521
|
+
setHighlightedOptionId(null)
|
|
522
|
+
setAnnouncement(`${getOptionById(tag).label} removed`)
|
|
523
|
+
|
|
524
|
+
inputRef.current.focus()
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const renderTags = () => {
|
|
528
|
+
return selectedOptionId.map((id, index) => (
|
|
529
|
+
<Tag
|
|
530
|
+
dismissible
|
|
531
|
+
key={id}
|
|
532
|
+
text={
|
|
533
|
+
<AccessibleContent alt={`Remove ${getOptionById(id).label}`}>
|
|
534
|
+
{getOptionById(id).label}
|
|
535
|
+
</AccessibleContent>
|
|
536
|
+
}
|
|
537
|
+
margin={
|
|
538
|
+
index > 0 ? 'xxx-small xx-small xxx-small 0' : '0 xx-small 0 0'
|
|
539
|
+
}
|
|
540
|
+
onClick={(e) => dismissTag(e, id)}
|
|
541
|
+
/>
|
|
542
|
+
))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<div>
|
|
547
|
+
<Select
|
|
548
|
+
renderLabel="Multiple Select"
|
|
549
|
+
assistiveText="Type or use arrow keys to navigate options. Multiple selections allowed."
|
|
550
|
+
inputValue={inputValue}
|
|
551
|
+
isShowingOptions={isShowingOptions}
|
|
552
|
+
inputRef={(el) => {
|
|
553
|
+
inputRef.current = el
|
|
554
|
+
}}
|
|
555
|
+
onBlur={handleBlur}
|
|
556
|
+
onInputChange={handleInputChange}
|
|
557
|
+
onRequestShowOptions={handleShowOptions}
|
|
558
|
+
onRequestHideOptions={handleHideOptions}
|
|
559
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
560
|
+
onRequestSelectOption={handleSelectOption}
|
|
561
|
+
onKeyDown={handleKeyDown}
|
|
562
|
+
renderBeforeInput={selectedOptionId.length > 0 ? renderTags() : null}
|
|
563
|
+
>
|
|
564
|
+
{filteredOptions.length > 0 ? (
|
|
565
|
+
filteredOptions.map((option, index) => {
|
|
566
|
+
if (selectedOptionId.indexOf(option.id) === -1) {
|
|
567
|
+
return (
|
|
568
|
+
<Select.Option
|
|
569
|
+
id={option.id}
|
|
570
|
+
key={option.id}
|
|
571
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
572
|
+
>
|
|
573
|
+
{option.label}
|
|
574
|
+
</Select.Option>
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
) : (
|
|
579
|
+
<Select.Option id="empty-option" key="empty-option">
|
|
580
|
+
---
|
|
581
|
+
</Select.Option>
|
|
582
|
+
)}
|
|
583
|
+
</Select>
|
|
584
|
+
</div>
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
render(
|
|
589
|
+
<View>
|
|
590
|
+
<MultipleSelectExample
|
|
591
|
+
options={[
|
|
592
|
+
{ id: 'opt1', label: 'Alaska' },
|
|
593
|
+
{ id: 'opt2', label: 'American Samoa' },
|
|
594
|
+
{ id: 'opt3', label: 'Arizona' },
|
|
595
|
+
{ id: 'opt4', label: 'Arkansas' },
|
|
596
|
+
{ id: 'opt5', label: 'California' },
|
|
597
|
+
{ id: 'opt6', label: 'Colorado' },
|
|
598
|
+
{ id: 'opt7', label: 'Connecticut' },
|
|
599
|
+
{ id: 'opt8', label: 'Delaware' },
|
|
600
|
+
{ id: 'opt9', label: 'District Of Columbia' },
|
|
601
|
+
{ id: 'opt10', label: 'Federated States Of Micronesia' },
|
|
602
|
+
{ id: 'opt11', label: 'Florida' },
|
|
603
|
+
{ id: 'opt12', label: 'Georgia (unavailable)' },
|
|
604
|
+
{ id: 'opt13', label: 'Guam' },
|
|
605
|
+
{ id: 'opt14', label: 'Hawaii' },
|
|
606
|
+
{ id: 'opt15', label: 'Idaho' },
|
|
607
|
+
{ id: 'opt16', label: 'Illinois' }
|
|
608
|
+
]}
|
|
609
|
+
/>
|
|
610
|
+
</View>
|
|
611
|
+
)
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
#### Composing option groups
|
|
615
|
+
|
|
616
|
+
In addition to `<Select.Option />` Select also accepts `<Select.Group />` as children. This is meant to serve the same purpose as `<optgroup>` elements. Group only requires you provide a label via its `renderLabel` prop. Groups and their associated options also accept icons or other stylistic additions if needed.
|
|
617
|
+
|
|
618
|
+
```js
|
|
619
|
+
---
|
|
620
|
+
type: example
|
|
621
|
+
---
|
|
622
|
+
const GroupSelectExample = ({ options }) => {
|
|
623
|
+
const [inputValue, setInputValue] = useState(options['Western'][0].label)
|
|
624
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
625
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
626
|
+
const [selectedOptionId, setSelectedOptionId] = useState(
|
|
627
|
+
options['Western'][0].id
|
|
628
|
+
)
|
|
629
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
630
|
+
const inputRef = useRef()
|
|
631
|
+
|
|
632
|
+
const focusInput = () => {
|
|
633
|
+
if (inputRef.current) {
|
|
634
|
+
inputRef.current.blur()
|
|
635
|
+
inputRef.current.focus()
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const getOptionById = (id) => {
|
|
640
|
+
let match = null
|
|
641
|
+
Object.keys(options).forEach((key, index) => {
|
|
642
|
+
for (let i = 0; i < options[key].length; i++) {
|
|
643
|
+
const option = options[key][i]
|
|
644
|
+
if (id === option.id) {
|
|
645
|
+
// return group property with the object just to make it easier
|
|
646
|
+
// to check which group the option belongs to
|
|
647
|
+
match = { ...option, group: key }
|
|
648
|
+
break
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
return match
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const getGroupChangedMessage = (newOption) => {
|
|
656
|
+
const currentOption = getOptionById(highlightedOptionId)
|
|
657
|
+
const isNewGroup =
|
|
658
|
+
!currentOption || currentOption.group !== newOption.group
|
|
659
|
+
let message = isNewGroup ? `Group ${newOption.group} entered. ` : ''
|
|
660
|
+
message += newOption.label
|
|
661
|
+
return message
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const handleShowOptions = (event) => {
|
|
665
|
+
setIsShowingOptions(true)
|
|
666
|
+
setHighlightedOptionId(null)
|
|
667
|
+
if (inputValue || selectedOptionId || Object.keys(options).length === 0) return
|
|
668
|
+
|
|
669
|
+
if ('key' in event) {
|
|
670
|
+
switch (event.key) {
|
|
671
|
+
case 'ArrowDown':
|
|
672
|
+
return handleHighlightOption(event, {
|
|
673
|
+
id: options[Object.keys(options)[0]][0].id
|
|
674
|
+
})
|
|
675
|
+
case 'ArrowUp':
|
|
676
|
+
return handleHighlightOption(event, {
|
|
677
|
+
id: Object.values(options).at(-1)?.at(-1)?.id
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const handleHideOptions = (event) => {
|
|
684
|
+
setIsShowingOptions(false)
|
|
685
|
+
setHighlightedOptionId(null)
|
|
686
|
+
setInputValue(getOptionById(selectedOptionId)?.label)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const handleBlur = (event) => {
|
|
690
|
+
setHighlightedOptionId(null)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const handleHighlightOption = (event, { id }) => {
|
|
694
|
+
event.persist()
|
|
695
|
+
const newOption = getOptionById(id)
|
|
696
|
+
setHighlightedOptionId(id)
|
|
697
|
+
setInputValue(inputValue)
|
|
698
|
+
setAnnouncement(getGroupChangedMessage(newOption))
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const handleSelectOption = (event, { id }) => {
|
|
702
|
+
focusInput()
|
|
703
|
+
setSelectedOptionId(id)
|
|
704
|
+
setInputValue(getOptionById(id).label)
|
|
705
|
+
setIsShowingOptions(false)
|
|
706
|
+
setAnnouncement(`${getOptionById(id).label} selected.`)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const renderLabel = (text, variant) => {
|
|
710
|
+
return (
|
|
711
|
+
<span>
|
|
712
|
+
<Badge
|
|
713
|
+
type="notification"
|
|
714
|
+
variant={variant}
|
|
715
|
+
standalone
|
|
716
|
+
margin="0 x-small xxx-small 0"
|
|
717
|
+
/>
|
|
718
|
+
{text}
|
|
719
|
+
</span>
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const renderGroup = () => {
|
|
724
|
+
return Object.keys(options).map((key, index) => {
|
|
725
|
+
const badgeVariant = key === 'Eastern' ? 'success' : 'primary'
|
|
726
|
+
return (
|
|
727
|
+
<Select.Group
|
|
728
|
+
key={index}
|
|
729
|
+
renderLabel={renderLabel(key, badgeVariant)}
|
|
730
|
+
>
|
|
731
|
+
{options[key].map((option) => (
|
|
732
|
+
<Select.Option
|
|
733
|
+
key={option.id}
|
|
734
|
+
id={option.id}
|
|
735
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
736
|
+
isSelected={option.id === selectedOptionId}
|
|
737
|
+
>
|
|
738
|
+
{option.label}
|
|
739
|
+
</Select.Option>
|
|
740
|
+
))}
|
|
741
|
+
</Select.Group>
|
|
742
|
+
)
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return (
|
|
747
|
+
<div>
|
|
748
|
+
<Select
|
|
749
|
+
renderLabel="Group Select"
|
|
750
|
+
assistiveText="Type or use arrow keys to navigate options."
|
|
751
|
+
inputValue={inputValue}
|
|
752
|
+
isShowingOptions={isShowingOptions}
|
|
753
|
+
onBlur={handleBlur}
|
|
754
|
+
onRequestShowOptions={handleShowOptions}
|
|
755
|
+
onRequestHideOptions={handleHideOptions}
|
|
756
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
757
|
+
onRequestSelectOption={handleSelectOption}
|
|
758
|
+
renderBeforeInput={
|
|
759
|
+
<Badge
|
|
760
|
+
type="notification"
|
|
761
|
+
variant={
|
|
762
|
+
getOptionById(selectedOptionId)?.group === 'Eastern'
|
|
763
|
+
? 'success'
|
|
764
|
+
: 'primary'
|
|
765
|
+
}
|
|
766
|
+
standalone
|
|
767
|
+
margin="0 0 xxx-small 0"
|
|
768
|
+
/>
|
|
769
|
+
}
|
|
770
|
+
inputRef={(el) => {
|
|
771
|
+
inputRef.current = el
|
|
772
|
+
}}
|
|
773
|
+
>
|
|
774
|
+
{renderGroup()}
|
|
775
|
+
</Select>
|
|
776
|
+
</div>
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
render(
|
|
781
|
+
<View>
|
|
782
|
+
<GroupSelectExample
|
|
783
|
+
options={{
|
|
784
|
+
Western: [
|
|
785
|
+
{ id: 'opt5', label: 'Alaska' },
|
|
786
|
+
{ id: 'opt6', label: 'California' },
|
|
787
|
+
{ id: 'opt7', label: 'Colorado' },
|
|
788
|
+
{ id: 'opt8', label: 'Idaho' }
|
|
789
|
+
],
|
|
790
|
+
Eastern: [
|
|
791
|
+
{ id: 'opt1', label: 'Alabama' },
|
|
792
|
+
{ id: 'opt2', label: 'Connecticut' },
|
|
793
|
+
{ id: 'opt3', label: 'Delaware' },
|
|
794
|
+
{ id: 'opt4', label: 'Illinois' }
|
|
795
|
+
]
|
|
796
|
+
}}
|
|
797
|
+
/>
|
|
798
|
+
</View>
|
|
799
|
+
)
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
##### Using groups with autocomplete on Safari
|
|
803
|
+
|
|
804
|
+
Due to a WebKit bug if you are using `Select.Group` with autocomplete, the screenreader won't announce highlight/selection changes. This only seems to be an issue in Safari. Here is an example how you can work around that:
|
|
805
|
+
|
|
806
|
+
```js
|
|
807
|
+
---
|
|
808
|
+
type: example
|
|
809
|
+
---
|
|
810
|
+
const GroupSelectAutocompleteExample = ({ options }) => {
|
|
811
|
+
const [inputValue, setInputValue] = useState('')
|
|
812
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
813
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
814
|
+
const [selectedOptionId, setSelectedOptionId] = useState(null)
|
|
815
|
+
const [filteredOptions, setFilteredOptions] = useState(options)
|
|
816
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
817
|
+
const inputRef = useRef()
|
|
818
|
+
|
|
819
|
+
const focusInput = () => {
|
|
820
|
+
if (inputRef.current) {
|
|
821
|
+
inputRef.current.blur()
|
|
822
|
+
inputRef.current.focus()
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const getOptionById = (id) => {
|
|
827
|
+
return Object.values(options)
|
|
828
|
+
.flat()
|
|
829
|
+
.find((o) => o?.id === id)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const filterOptions = (value, options) => {
|
|
833
|
+
const filteredOptions = {}
|
|
834
|
+
Object.keys(options).forEach((key) => {
|
|
835
|
+
filteredOptions[key] = options[key]?.filter((option) =>
|
|
836
|
+
option.label.toLowerCase().includes(value.toLowerCase())
|
|
837
|
+
)
|
|
838
|
+
})
|
|
839
|
+
const optionsWithoutEmptyKeys = Object.keys(filteredOptions)
|
|
840
|
+
.filter((k) => filteredOptions[k].length > 0)
|
|
841
|
+
.reduce((a, k) => ({ ...a, [k]: filteredOptions[k] }), {})
|
|
842
|
+
return optionsWithoutEmptyKeys
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const handleShowOptions = (event) => {
|
|
846
|
+
setIsShowingOptions(true)
|
|
847
|
+
setHighlightedOptionId(null)
|
|
848
|
+
|
|
849
|
+
if (inputValue || selectedOptionId || Object.keys(options).length === 0) return
|
|
850
|
+
|
|
851
|
+
if ('key' in event) {
|
|
852
|
+
switch (event.key) {
|
|
853
|
+
case 'ArrowDown':
|
|
854
|
+
return handleHighlightOption(event, {
|
|
855
|
+
id: options[Object.keys(options)[0]][0].id
|
|
856
|
+
})
|
|
857
|
+
case 'ArrowUp':
|
|
858
|
+
return handleHighlightOption(event, {
|
|
859
|
+
id: Object.values(options).at(-1)?.at(-1)?.id
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const handleHideOptions = (event) => {
|
|
866
|
+
setIsShowingOptions(false)
|
|
867
|
+
setHighlightedOptionId(null)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const handleBlur = (event) => {
|
|
871
|
+
setHighlightedOptionId(null)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const handleHighlightOption = (event, { id }) => {
|
|
875
|
+
event.persist()
|
|
876
|
+
const option = getOptionById(id)
|
|
877
|
+
setTimeout(() => {
|
|
878
|
+
setAnnouncement(option.label)
|
|
879
|
+
}, 0)
|
|
880
|
+
setHighlightedOptionId(id)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const handleSelectOption = (event, { id }) => {
|
|
884
|
+
const option = getOptionById(id)
|
|
885
|
+
if (!option) return // prevent selecting of empty option
|
|
886
|
+
focusInput()
|
|
887
|
+
setSelectedOptionId(id)
|
|
888
|
+
setInputValue(option.label)
|
|
889
|
+
setIsShowingOptions(false)
|
|
890
|
+
setFilteredOptions(options)
|
|
891
|
+
setAnnouncement(option.label)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const handleInputChange = (event) => {
|
|
895
|
+
const value = event.target.value
|
|
896
|
+
const newOptions = filterOptions(value, options)
|
|
897
|
+
setInputValue(value)
|
|
898
|
+
setFilteredOptions(newOptions)
|
|
899
|
+
setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null)
|
|
900
|
+
setIsShowingOptions(true)
|
|
901
|
+
setSelectedOptionId(value === '' ? null : selectedOptionId)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const renderGroup = () => {
|
|
905
|
+
return Object.keys(filteredOptions).map((key, index) => {
|
|
906
|
+
return (
|
|
907
|
+
<Select.Group key={index} renderLabel={key}>
|
|
908
|
+
{filteredOptions[key].map((option) => (
|
|
909
|
+
<Select.Option
|
|
910
|
+
key={option.id}
|
|
911
|
+
id={option.id}
|
|
912
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
913
|
+
isSelected={option.id === selectedOptionId}
|
|
914
|
+
>
|
|
915
|
+
{option.label}
|
|
916
|
+
</Select.Option>
|
|
917
|
+
))}
|
|
918
|
+
</Select.Group>
|
|
919
|
+
)
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const renderScreenReaderHelper = () => {
|
|
924
|
+
return (
|
|
925
|
+
window.safari && (
|
|
926
|
+
<ScreenReaderContent>
|
|
927
|
+
<span role="alert" aria-live="assertive">
|
|
928
|
+
{announcement}
|
|
929
|
+
</span>
|
|
930
|
+
</ScreenReaderContent>
|
|
931
|
+
)
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return (
|
|
936
|
+
<div>
|
|
937
|
+
<Select
|
|
938
|
+
placeholder="Start typing to search..."
|
|
939
|
+
renderLabel="Group Select with autocomplete"
|
|
940
|
+
assistiveText="Type or use arrow keys to navigate options."
|
|
941
|
+
inputValue={inputValue}
|
|
942
|
+
isShowingOptions={isShowingOptions}
|
|
943
|
+
onBlur={handleBlur}
|
|
944
|
+
onInputChange={handleInputChange}
|
|
945
|
+
onRequestShowOptions={handleShowOptions}
|
|
946
|
+
onRequestHideOptions={handleHideOptions}
|
|
947
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
948
|
+
onRequestSelectOption={handleSelectOption}
|
|
949
|
+
inputRef={(el) => {
|
|
950
|
+
inputRef.current = el
|
|
951
|
+
}}
|
|
952
|
+
>
|
|
953
|
+
{renderGroup()}
|
|
954
|
+
</Select>
|
|
955
|
+
{renderScreenReaderHelper()}
|
|
956
|
+
</div>
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
render(
|
|
961
|
+
<View>
|
|
962
|
+
<GroupSelectAutocompleteExample
|
|
963
|
+
options={{
|
|
964
|
+
Western: [
|
|
965
|
+
{ id: 'opt5', label: 'Alaska' },
|
|
966
|
+
{ id: 'opt6', label: 'California' },
|
|
967
|
+
{ id: 'opt7', label: 'Colorado' },
|
|
968
|
+
{ id: 'opt8', label: 'Idaho' }
|
|
969
|
+
],
|
|
970
|
+
Eastern: [
|
|
971
|
+
{ id: 'opt1', label: 'Alabama' },
|
|
972
|
+
{ id: 'opt2', label: 'Connecticut' },
|
|
973
|
+
{ id: 'opt3', label: 'Delaware' },
|
|
974
|
+
{ id: '4', label: 'Illinois' }
|
|
975
|
+
]
|
|
976
|
+
}}
|
|
977
|
+
/>
|
|
978
|
+
</View>
|
|
979
|
+
)
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
#### Asynchronous option loading
|
|
983
|
+
|
|
984
|
+
If no results match the user's search, it's recommended to leave `isShowingOptions` as `true` and to display an "empty option" as a way of communicating that there are no matches. Similarly, it's helpful to display a [Spinner](Spinner) in an empty option while options load.
|
|
985
|
+
|
|
986
|
+
```js
|
|
987
|
+
---
|
|
988
|
+
type: example
|
|
989
|
+
---
|
|
990
|
+
const AsyncExample = ({ options }) => {
|
|
991
|
+
const [inputValue, setInputValue] = useState('')
|
|
992
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
993
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
994
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
995
|
+
const [selectedOptionId, setSelectedOptionId] = useState(null)
|
|
996
|
+
const [selectedOptionLabel, setSelectedOptionLabel] = useState('')
|
|
997
|
+
const [filteredOptions, setFilteredOptions] = useState([])
|
|
998
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
999
|
+
const inputRef = useRef()
|
|
1000
|
+
|
|
1001
|
+
const focusInput = () => {
|
|
1002
|
+
if (inputRef.current) {
|
|
1003
|
+
inputRef.current.blur()
|
|
1004
|
+
inputRef.current.focus()
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let timeoutId = null
|
|
1009
|
+
|
|
1010
|
+
const getOptionById = (queryId) => {
|
|
1011
|
+
return filteredOptions.find(({ id }) => id === queryId)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const filterOptions = (value) => {
|
|
1015
|
+
return options.filter((option) =>
|
|
1016
|
+
option.label.toLowerCase().startsWith(value.toLowerCase())
|
|
1017
|
+
)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const matchValue = () => {
|
|
1021
|
+
// an option matching user input exists
|
|
1022
|
+
if (filteredOptions.length === 1) {
|
|
1023
|
+
const onlyOption = filteredOptions[0]
|
|
1024
|
+
// automatically select the matching option
|
|
1025
|
+
if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) {
|
|
1026
|
+
setInputValue(onlyOption.label)
|
|
1027
|
+
setSelectedOptionId(onlyOption.id)
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// allow user to return to empty input and no selection
|
|
1032
|
+
if (inputValue.length === 0) {
|
|
1033
|
+
setSelectedOptionId(null)
|
|
1034
|
+
setFilteredOptions([])
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
// no match found, return selected option label to input
|
|
1038
|
+
if (selectedOptionId) {
|
|
1039
|
+
setInputValue(selectedOptionLabel)
|
|
1040
|
+
return
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const handleShowOptions = (event) => {
|
|
1045
|
+
setIsShowingOptions(true)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const handleHideOptions = (event) => {
|
|
1049
|
+
setIsShowingOptions(false)
|
|
1050
|
+
setHighlightedOptionId(null)
|
|
1051
|
+
setAnnouncement('List collapsed.')
|
|
1052
|
+
matchValue()
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const handleBlur = (event) => {
|
|
1056
|
+
setHighlightedOptionId(null)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const handleHighlightOption = (event, { id }) => {
|
|
1060
|
+
event.persist()
|
|
1061
|
+
const option = getOptionById(id)
|
|
1062
|
+
if (!option) return // prevent highlighting of empty option
|
|
1063
|
+
|
|
1064
|
+
setHighlightedOptionId(id)
|
|
1065
|
+
setInputValue(inputValue)
|
|
1066
|
+
setAnnouncement(option.label)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const handleSelectOption = (event, { id }) => {
|
|
1070
|
+
const option = getOptionById(id)
|
|
1071
|
+
if (!option) return // prevent selecting of empty option
|
|
1072
|
+
focusInput()
|
|
1073
|
+
setSelectedOptionId(id)
|
|
1074
|
+
setSelectedOptionLabel(option.label)
|
|
1075
|
+
setInputValue(option.label)
|
|
1076
|
+
setIsShowingOptions(false)
|
|
1077
|
+
setAnnouncement(`${option.label} selected. List collapsed.`)
|
|
1078
|
+
setFilteredOptions([getOptionById(id)])
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const handleInputChange = (event) => {
|
|
1082
|
+
const value = event.target.value
|
|
1083
|
+
clearTimeout(timeoutId)
|
|
1084
|
+
|
|
1085
|
+
if (!value || value === '') {
|
|
1086
|
+
setIsLoading(false)
|
|
1087
|
+
setInputValue(value)
|
|
1088
|
+
setIsShowingOptions(true)
|
|
1089
|
+
setSelectedOptionId(null)
|
|
1090
|
+
setSelectedOptionLabel(null)
|
|
1091
|
+
setFilteredOptions([])
|
|
1092
|
+
} else {
|
|
1093
|
+
setIsLoading(true)
|
|
1094
|
+
setInputValue(value)
|
|
1095
|
+
setIsShowingOptions(true)
|
|
1096
|
+
setFilteredOptions([])
|
|
1097
|
+
setHighlightedOptionId(null)
|
|
1098
|
+
setAnnouncement('Loading options.')
|
|
1099
|
+
|
|
1100
|
+
timeoutId = setTimeout(() => {
|
|
1101
|
+
const newOptions = filterOptions(value)
|
|
1102
|
+
setFilteredOptions(newOptions)
|
|
1103
|
+
setIsLoading(false)
|
|
1104
|
+
setAnnouncement(`${newOptions.length} options available.`)
|
|
1105
|
+
}, 1500)
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return (
|
|
1110
|
+
<div>
|
|
1111
|
+
<Select
|
|
1112
|
+
renderLabel="Async Select"
|
|
1113
|
+
assistiveText="Type to search"
|
|
1114
|
+
inputValue={inputValue}
|
|
1115
|
+
isShowingOptions={isShowingOptions}
|
|
1116
|
+
onBlur={handleBlur}
|
|
1117
|
+
onInputChange={handleInputChange}
|
|
1118
|
+
onRequestShowOptions={handleShowOptions}
|
|
1119
|
+
onRequestHideOptions={handleHideOptions}
|
|
1120
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
1121
|
+
onRequestSelectOption={handleSelectOption}
|
|
1122
|
+
inputRef={(el) => {
|
|
1123
|
+
inputRef.current = el
|
|
1124
|
+
}}
|
|
1125
|
+
>
|
|
1126
|
+
{filteredOptions.length > 0 ? (
|
|
1127
|
+
filteredOptions.map((option) => {
|
|
1128
|
+
return (
|
|
1129
|
+
<Select.Option
|
|
1130
|
+
id={option.id}
|
|
1131
|
+
key={option.id}
|
|
1132
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
1133
|
+
isSelected={option.id === selectedOptionId}
|
|
1134
|
+
isDisabled={option.disabled}
|
|
1135
|
+
renderBeforeLabel={<User2InstUIIcon />}
|
|
1136
|
+
>
|
|
1137
|
+
{option.label}
|
|
1138
|
+
</Select.Option>
|
|
1139
|
+
)
|
|
1140
|
+
})
|
|
1141
|
+
) : (
|
|
1142
|
+
<Select.Option id="empty-option" key="empty-option">
|
|
1143
|
+
{isLoading ? (
|
|
1144
|
+
<Spinner renderTitle="Loading" size="x-small" />
|
|
1145
|
+
) : inputValue !== '' ? (
|
|
1146
|
+
'No results'
|
|
1147
|
+
) : (
|
|
1148
|
+
'Type to search'
|
|
1149
|
+
)}
|
|
1150
|
+
</Select.Option>
|
|
1151
|
+
)}
|
|
1152
|
+
</Select>
|
|
1153
|
+
</div>
|
|
1154
|
+
)
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
render(
|
|
1158
|
+
<View>
|
|
1159
|
+
<AsyncExample
|
|
1160
|
+
options={[
|
|
1161
|
+
{ id: 'opt0', label: 'Aaron Aaronson' },
|
|
1162
|
+
{ id: 'opt1', label: 'Amber Murphy' },
|
|
1163
|
+
{ id: 'opt2', label: 'Andrew Miller' },
|
|
1164
|
+
{ id: 'opt3', label: 'Barbara Ward' },
|
|
1165
|
+
{ id: 'opt4', label: 'Byron Cranston', disabled: true },
|
|
1166
|
+
{ id: 'opt5', label: 'Dennis Reynolds' },
|
|
1167
|
+
{ id: 'opt6', label: 'Dee Reynolds' },
|
|
1168
|
+
{ id: 'opt7', label: 'Ezra Betterthan' },
|
|
1169
|
+
{ id: 'opt8', label: 'Jeff Spicoli' },
|
|
1170
|
+
{ id: 'opt9', label: 'Joseph Smith' },
|
|
1171
|
+
{ id: 'opt10', label: 'Jasmine Diaz' },
|
|
1172
|
+
{ id: 'opt11', label: 'Martin Harris' },
|
|
1173
|
+
{ id: 'opt12', label: 'Michael Morgan', disabled: true },
|
|
1174
|
+
{ id: 'opt13', label: 'Michelle Rodriguez' },
|
|
1175
|
+
{ id: 'opt14', label: 'Ziggy Stardust' }
|
|
1176
|
+
]}
|
|
1177
|
+
/>
|
|
1178
|
+
</View>
|
|
1179
|
+
)
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
### Icons
|
|
1183
|
+
|
|
1184
|
+
To display icons (or other elements) before or after an option, pass it via the `renderBeforeLabel` and `renderAfterLabel` prop to `Select.Option`. You can pass a function as well, which will have a `props` parameter, so you can access the properties of that `Select.Option` (e.g. if it is currently `isHighlighted`). The available props are: `[ id, isDisabled, isSelected, isHighlighted, children ]`.
|
|
1185
|
+
|
|
1186
|
+
```js
|
|
1187
|
+
---
|
|
1188
|
+
type: example
|
|
1189
|
+
---
|
|
1190
|
+
const SingleSelectExample = ({ options }) => {
|
|
1191
|
+
const [inputValue, setInputValue] = useState(options[0].label)
|
|
1192
|
+
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
|
1193
|
+
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
|
1194
|
+
const [selectedOptionId, setSelectedOptionId] = useState(options[0].id)
|
|
1195
|
+
const [announcement, setAnnouncement] = useState(null)
|
|
1196
|
+
const inputRef = useRef()
|
|
1197
|
+
|
|
1198
|
+
const focusInput = () => {
|
|
1199
|
+
if (inputRef.current) {
|
|
1200
|
+
inputRef.current.blur()
|
|
1201
|
+
inputRef.current.focus()
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const getOptionById = (queryId) => {
|
|
1206
|
+
return options.find(({ id }) => id === queryId)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const handleShowOptions = (event) => {
|
|
1210
|
+
setIsShowingOptions(true)
|
|
1211
|
+
|
|
1212
|
+
if (inputValue || selectedOptionId || options.length === 0) return
|
|
1213
|
+
|
|
1214
|
+
if ('key' in event) {
|
|
1215
|
+
switch (event.key) {
|
|
1216
|
+
case 'ArrowDown':
|
|
1217
|
+
return handleHighlightOption(event, { id: options[0].id })
|
|
1218
|
+
case 'ArrowUp':
|
|
1219
|
+
return handleHighlightOption(event, {
|
|
1220
|
+
id: options[options.length - 1].id
|
|
1221
|
+
})
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const handleHideOptions = (event) => {
|
|
1227
|
+
const option = getOptionById(selectedOptionId)?.label
|
|
1228
|
+
setIsShowingOptions(false)
|
|
1229
|
+
setHighlightedOptionId(null)
|
|
1230
|
+
setInputValue(selectedOptionId ? option : '')
|
|
1231
|
+
setAnnouncement('List collapsed.')
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const handleBlur = (event) => {
|
|
1235
|
+
setHighlightedOptionId(null)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const handleHighlightOption = (event, { id }) => {
|
|
1239
|
+
event.persist()
|
|
1240
|
+
const optionsAvailable = `${options.length} options available.`
|
|
1241
|
+
const nowOpen = !isShowingOptions
|
|
1242
|
+
? `List expanded. ${optionsAvailable}`
|
|
1243
|
+
: ''
|
|
1244
|
+
const option = getOptionById(id).label
|
|
1245
|
+
setHighlightedOptionId(id)
|
|
1246
|
+
setInputValue(inputValue)
|
|
1247
|
+
setAnnouncement(`${option} ${nowOpen}`)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const handleSelectOption = (event, { id }) => {
|
|
1251
|
+
const option = getOptionById(id).label
|
|
1252
|
+
focusInput()
|
|
1253
|
+
setSelectedOptionId(id)
|
|
1254
|
+
setInputValue(option)
|
|
1255
|
+
setIsShowingOptions(false)
|
|
1256
|
+
setAnnouncement(`"${option}" selected. List collapsed.`)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return (
|
|
1260
|
+
<div>
|
|
1261
|
+
<Select
|
|
1262
|
+
renderLabel="Option Icons"
|
|
1263
|
+
assistiveText="Use arrow keys to navigate options."
|
|
1264
|
+
inputValue={inputValue}
|
|
1265
|
+
isShowingOptions={isShowingOptions}
|
|
1266
|
+
onBlur={handleBlur}
|
|
1267
|
+
onRequestShowOptions={handleShowOptions}
|
|
1268
|
+
onRequestHideOptions={handleHideOptions}
|
|
1269
|
+
onRequestHighlightOption={handleHighlightOption}
|
|
1270
|
+
onRequestSelectOption={handleSelectOption}
|
|
1271
|
+
inputRef={(el) => {
|
|
1272
|
+
inputRef.current = el
|
|
1273
|
+
}}
|
|
1274
|
+
>
|
|
1275
|
+
{options.map((option) => {
|
|
1276
|
+
return (
|
|
1277
|
+
<Select.Option
|
|
1278
|
+
id={option.id}
|
|
1279
|
+
key={option.id}
|
|
1280
|
+
isHighlighted={option.id === highlightedOptionId}
|
|
1281
|
+
isSelected={option.id === selectedOptionId}
|
|
1282
|
+
renderBeforeLabel={option.renderBeforeLabel}
|
|
1283
|
+
>
|
|
1284
|
+
{option.label}
|
|
1285
|
+
</Select.Option>
|
|
1286
|
+
)
|
|
1287
|
+
})}
|
|
1288
|
+
</Select>
|
|
1289
|
+
</div>
|
|
1290
|
+
)
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
render(
|
|
1294
|
+
<View>
|
|
1295
|
+
<SingleSelectExample
|
|
1296
|
+
options={[
|
|
1297
|
+
{
|
|
1298
|
+
id: 'opt1',
|
|
1299
|
+
label: 'Text',
|
|
1300
|
+
renderBeforeLabel: 'XY'
|
|
1301
|
+
},
|
|
1302
|
+
{
|
|
1303
|
+
id: 'opt2',
|
|
1304
|
+
label: 'Icon',
|
|
1305
|
+
renderBeforeLabel: <CheckInstUIIcon />
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
id: 'opt3',
|
|
1309
|
+
label: 'Colored Icon',
|
|
1310
|
+
renderBeforeLabel: (props) => {
|
|
1311
|
+
let color = 'infoColor'
|
|
1312
|
+
if (props.isHighlighted) color = 'baseColor'
|
|
1313
|
+
if (props.isSelected) color = 'inverseColor'
|
|
1314
|
+
if (props.isDisabled) color = 'disabledBaseColor'
|
|
1315
|
+
return <VerifiedInstUIIcon color={color}/>
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
]}
|
|
1319
|
+
/>
|
|
1320
|
+
</View>
|
|
1321
|
+
)
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
#### Providing assistive text for screen readers
|
|
1325
|
+
|
|
1326
|
+
It's important to ensure screen reader users receive instruction and feedback while interacting with a `Select`, but screen reader support for the `combobox` role varies. The `assistiveText` prop should always be used to explain how a keyboard user can make a selection. Additionally, a live region should be updated with feedback as the component is interacted with, such as when options are filtered or highlighted. Using an [Alert](Alert) with the `screenReaderOnly` prop is the easiest way to do this.
|
|
1327
|
+
|
|
1328
|
+
> Note: This component uses a native `input` field to render the selected value. When it's included in a native HTML `form`, the text value will be sent to the backend instead of anything specified in the `value` field of the `Select.Option`-s. We do not recommend to use this component this way, rather write your own code that collects information and sends it to the backend.
|
|
1329
|
+
|
|
1330
|
+
```js
|
|
1331
|
+
---
|
|
1332
|
+
type: embed
|
|
1333
|
+
---
|
|
1334
|
+
<Guidelines>
|
|
1335
|
+
<Figure recommendation="a11y" title="Accessibility">
|
|
1336
|
+
<Figure.Item>To ensure Select is accessible for iOS VoiceOver users, the input field’s focus must be blurred and then reapplied after selecting an option and closing the listbox. The examples above demonstrate this behavior.
|
|
1337
|
+
</Figure.Item>
|
|
1338
|
+
<Figure.Item>If no option is selected initially, pressing the down arrow should open the listbox and move focus to the first option, while pressing up should move focus to the last item. You can see this behavior in the examples above.
|
|
1339
|
+
</Figure.Item>
|
|
1340
|
+
</Figure>
|
|
1341
|
+
</Guidelines>
|
|
1342
|
+
```
|