@leafygreen-ui/combobox 1.0.3 → 1.2.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/CHANGELOG.md +59 -0
- package/README.md +2 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/Combobox.d.ts +7 -1
- package/dist/Combobox.d.ts.map +1 -1
- package/dist/Combobox.styles.d.ts +7 -3
- package/dist/Combobox.styles.d.ts.map +1 -1
- package/dist/Combobox.types.d.ts +33 -6
- package/dist/Combobox.types.d.ts.map +1 -1
- package/dist/ComboboxContext.d.ts +1 -1
- package/dist/ComboboxContext.d.ts.map +1 -1
- package/dist/ComboboxOption.d.ts.map +1 -1
- package/dist/ComboboxTestUtils.d.ts +1 -2
- package/dist/ComboboxTestUtils.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/OptionObjectUtils.d.ts +5 -0
- package/dist/utils/OptionObjectUtils.d.ts.map +1 -0
- package/dist/utils/flattenChildren.d.ts +11 -0
- package/dist/utils/flattenChildren.d.ts.map +1 -0
- package/dist/utils/getNameAndValue.d.ts +14 -0
- package/dist/utils/getNameAndValue.d.ts.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/wrapJSX.d.ts +14 -0
- package/dist/utils/wrapJSX.d.ts.map +1 -0
- package/package.json +20 -10
- package/src/Chip.tsx +16 -9
- package/src/Combobox.spec.tsx +322 -139
- package/src/Combobox.story.tsx +274 -248
- package/src/Combobox.styles.ts +94 -24
- package/src/Combobox.tsx +446 -266
- package/src/Combobox.types.ts +43 -6
- package/src/ComboboxContext.tsx +2 -2
- package/src/ComboboxOption.tsx +34 -8
- package/src/ComboboxTestUtils.tsx +22 -8
- package/src/utils/ComboboxUtils.spec.tsx +227 -0
- package/src/utils/OptionObjectUtils.ts +26 -0
- package/src/utils/flattenChildren.tsx +47 -0
- package/src/utils/getNameAndValue.ts +23 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/wrapJSX.tsx +54 -0
- package/tsconfig.json +3 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/util.d.ts +0 -53
- package/dist/util.d.ts.map +0 -1
- package/src/util.tsx +0 -117
package/src/Combobox.tsx
CHANGED
|
@@ -9,48 +9,59 @@ import { clone, isArray, isEqual, isNull, isString, isUndefined } from 'lodash';
|
|
|
9
9
|
import { Description, Label } from '@leafygreen-ui/typography';
|
|
10
10
|
import Popover from '@leafygreen-ui/popover';
|
|
11
11
|
import {
|
|
12
|
+
useAvailableSpace,
|
|
12
13
|
useDynamicRefs,
|
|
13
14
|
useEventListener,
|
|
14
15
|
useIdAllocator,
|
|
15
16
|
usePrevious,
|
|
16
|
-
useViewportSize,
|
|
17
17
|
} from '@leafygreen-ui/hooks';
|
|
18
|
-
import InteractionRing from '@leafygreen-ui/interaction-ring';
|
|
19
18
|
import Icon from '@leafygreen-ui/icon';
|
|
20
19
|
import IconButton from '@leafygreen-ui/icon-button';
|
|
21
20
|
import { cx } from '@leafygreen-ui/emotion';
|
|
22
21
|
import { uiColors } from '@leafygreen-ui/palette';
|
|
23
|
-
import { consoleOnce, isComponentType } from '@leafygreen-ui/lib';
|
|
22
|
+
import { consoleOnce, isComponentType, keyMap } from '@leafygreen-ui/lib';
|
|
24
23
|
import {
|
|
25
24
|
ComboboxProps,
|
|
26
25
|
getNullSelection,
|
|
27
26
|
onChangeType,
|
|
28
27
|
SelectValueType,
|
|
28
|
+
OptionObject,
|
|
29
|
+
ComboboxElement,
|
|
30
|
+
ComboboxSize,
|
|
29
31
|
} from './Combobox.types';
|
|
30
32
|
import { ComboboxContext } from './ComboboxContext';
|
|
31
33
|
import { InternalComboboxOption } from './ComboboxOption';
|
|
32
34
|
import { Chip } from './Chip';
|
|
33
35
|
import {
|
|
34
|
-
|
|
36
|
+
clearButtonStyle,
|
|
37
|
+
clearButtonFocusOverrideStyles,
|
|
38
|
+
comboboxFocusStyle,
|
|
35
39
|
comboboxParentStyle,
|
|
36
40
|
comboboxStyle,
|
|
37
41
|
endIcon,
|
|
38
42
|
errorMessageStyle,
|
|
39
43
|
inputElementStyle,
|
|
40
44
|
inputWrapperStyle,
|
|
41
|
-
interactionRingColor,
|
|
42
|
-
interactionRingStyle,
|
|
43
45
|
loadingIconStyle,
|
|
44
46
|
menuList,
|
|
45
47
|
menuMessage,
|
|
46
48
|
menuStyle,
|
|
47
49
|
menuWrapperStyle,
|
|
50
|
+
_tempLabelDescriptionOverrideStyle,
|
|
48
51
|
} from './Combobox.styles';
|
|
49
52
|
import { InternalComboboxGroup } from './ComboboxGroup';
|
|
50
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
flattenChildren,
|
|
55
|
+
getOptionObjectFromValue,
|
|
56
|
+
getDisplayNameForValue,
|
|
57
|
+
getValueForDisplayName,
|
|
58
|
+
getNameAndValue,
|
|
59
|
+
} from './utils';
|
|
51
60
|
|
|
52
61
|
/**
|
|
53
|
-
*
|
|
62
|
+
* Combobox is a combination of a Select and TextInput,
|
|
63
|
+
* allowing the user to either type a value directly or select a value from the list.
|
|
64
|
+
* Can be configured to select a single or multiple options.
|
|
54
65
|
*/
|
|
55
66
|
export default function Combobox<M extends boolean>({
|
|
56
67
|
children,
|
|
@@ -59,7 +70,7 @@ export default function Combobox<M extends boolean>({
|
|
|
59
70
|
placeholder = 'Select',
|
|
60
71
|
'aria-label': ariaLabel,
|
|
61
72
|
disabled = false,
|
|
62
|
-
size =
|
|
73
|
+
size = ComboboxSize.Default,
|
|
63
74
|
darkMode = false,
|
|
64
75
|
state = 'none',
|
|
65
76
|
errorMessage,
|
|
@@ -100,8 +111,10 @@ export default function Combobox<M extends boolean>({
|
|
|
100
111
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
101
112
|
|
|
102
113
|
const [isOpen, setOpen] = useState(false);
|
|
103
|
-
const
|
|
104
|
-
const [
|
|
114
|
+
const wasOpen = usePrevious(isOpen);
|
|
115
|
+
const [highlightedOption, sethighlightedOption] = useState<string | null>(
|
|
116
|
+
null,
|
|
117
|
+
);
|
|
105
118
|
const [selection, setSelection] = useState<SelectValueType<M> | null>(null);
|
|
106
119
|
const prevSelection = usePrevious(selection);
|
|
107
120
|
const [inputValue, setInputValue] = useState<string>('');
|
|
@@ -112,7 +125,25 @@ export default function Combobox<M extends boolean>({
|
|
|
112
125
|
!isNull(selection) &&
|
|
113
126
|
((isArray(selection) && selection.length > 0) || isString(selection));
|
|
114
127
|
|
|
115
|
-
|
|
128
|
+
const placeholderValue =
|
|
129
|
+
multiselect && isArray(selection) && selection.length > 0
|
|
130
|
+
? undefined
|
|
131
|
+
: placeholder;
|
|
132
|
+
|
|
133
|
+
const closeMenu = () => setOpen(false);
|
|
134
|
+
const openMenu = () => setOpen(true);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Array of all of the options objects
|
|
138
|
+
*/
|
|
139
|
+
const allOptions: Array<OptionObject> = useMemo(
|
|
140
|
+
() => flattenChildren(children),
|
|
141
|
+
[children],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Utility function that tells Typescript whether selection is multiselect
|
|
146
|
+
*/
|
|
116
147
|
const isMultiselect = useCallback(
|
|
117
148
|
<T extends string>(val?: Array<T> | T | null): val is Array<T> => {
|
|
118
149
|
if (multiselect && (typeof val == 'string' || typeof val == 'number')) {
|
|
@@ -130,7 +161,10 @@ export default function Combobox<M extends boolean>({
|
|
|
130
161
|
[multiselect],
|
|
131
162
|
);
|
|
132
163
|
|
|
133
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Forces focus of input box
|
|
166
|
+
* @param cursorPos index the cursor should be set to
|
|
167
|
+
*/
|
|
134
168
|
const setInputFocus = useCallback(
|
|
135
169
|
(cursorPos?: number) => {
|
|
136
170
|
if (!disabled && inputRef && inputRef.current) {
|
|
@@ -143,7 +177,11 @@ export default function Combobox<M extends boolean>({
|
|
|
143
177
|
[disabled],
|
|
144
178
|
);
|
|
145
179
|
|
|
146
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Update selection.
|
|
182
|
+
* This behaves differently in multi. vs single select.
|
|
183
|
+
* @param value option value the selection should be set to
|
|
184
|
+
*/
|
|
147
185
|
const updateSelection = useCallback(
|
|
148
186
|
(value: string | null) => {
|
|
149
187
|
if (isMultiselect(selection)) {
|
|
@@ -177,33 +215,50 @@ export default function Combobox<M extends boolean>({
|
|
|
177
215
|
[isMultiselect, onChange, selection],
|
|
178
216
|
);
|
|
179
217
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
: placeholder;
|
|
193
|
-
|
|
194
|
-
const allOptions = useMemo(() => flattenChildren(children), [children]);
|
|
218
|
+
/**
|
|
219
|
+
* Returns whether a given value is included in, or equal to, the current selection
|
|
220
|
+
* @param value the option value to check
|
|
221
|
+
*/
|
|
222
|
+
const isValueCurrentSelection = useCallback(
|
|
223
|
+
(value: string): boolean => {
|
|
224
|
+
return isMultiselect(selection)
|
|
225
|
+
? selection.includes(value)
|
|
226
|
+
: value === selection;
|
|
227
|
+
},
|
|
228
|
+
[isMultiselect, selection],
|
|
229
|
+
);
|
|
195
230
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Returns whether given text is included in, or equal to, the current selection.
|
|
233
|
+
* Similar to `isValueCurrentSelection`, but assumes the text argument is the `displayName` for the selection
|
|
234
|
+
* @param text the text to check
|
|
235
|
+
*/
|
|
236
|
+
const isTextCurrentSelection = useCallback(
|
|
237
|
+
(text: string): boolean => {
|
|
238
|
+
const value = getValueForDisplayName(text, allOptions);
|
|
239
|
+
return isValueCurrentSelection(value);
|
|
201
240
|
},
|
|
202
|
-
[allOptions],
|
|
241
|
+
[allOptions, isValueCurrentSelection],
|
|
203
242
|
);
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Returns whether the provided option is disabled
|
|
246
|
+
* @param option the option value or OptionObject to check
|
|
247
|
+
*/
|
|
248
|
+
const isOptionDisabled = (option: string | OptionObject): boolean => {
|
|
249
|
+
if (typeof option === 'string') {
|
|
250
|
+
const optionObj = getOptionObjectFromValue(option, allOptions);
|
|
251
|
+
return !!optionObj?.isDisabled;
|
|
252
|
+
} else {
|
|
253
|
+
return !!option.isDisabled;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Computes whether the option is visible based on the current input
|
|
259
|
+
* @param option the option value or OptionObject to compute
|
|
260
|
+
*/
|
|
261
|
+
const shouldOptionBeVisible = useCallback(
|
|
207
262
|
(option: string | OptionObject): boolean => {
|
|
208
263
|
const value = typeof option === 'string' ? option : option.value;
|
|
209
264
|
|
|
@@ -212,21 +267,40 @@ export default function Combobox<M extends boolean>({
|
|
|
212
267
|
return filteredOptions.includes(value);
|
|
213
268
|
}
|
|
214
269
|
|
|
270
|
+
// If the text input value is the current selection
|
|
271
|
+
// (or included in the selection)
|
|
272
|
+
// then all options should be visible
|
|
273
|
+
if (isTextCurrentSelection(inputValue)) {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
215
277
|
// otherwise, we do our own filtering
|
|
216
278
|
const displayName =
|
|
217
279
|
typeof option === 'string'
|
|
218
|
-
? getDisplayNameForValue(value)
|
|
280
|
+
? getDisplayNameForValue(value, allOptions)
|
|
219
281
|
: option.displayName;
|
|
220
|
-
|
|
282
|
+
|
|
283
|
+
const isValueInDisplayName = displayName
|
|
284
|
+
.toLowerCase()
|
|
285
|
+
.includes(inputValue.toLowerCase());
|
|
286
|
+
|
|
287
|
+
return isValueInDisplayName;
|
|
221
288
|
},
|
|
222
|
-
[filteredOptions,
|
|
289
|
+
[filteredOptions, isTextCurrentSelection, inputValue, allOptions],
|
|
223
290
|
);
|
|
224
291
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
292
|
+
/**
|
|
293
|
+
* The array of visible options objects
|
|
294
|
+
*/
|
|
295
|
+
const visibleOptions: Array<OptionObject> = useMemo(
|
|
296
|
+
() => allOptions.filter(shouldOptionBeVisible),
|
|
297
|
+
[allOptions, shouldOptionBeVisible],
|
|
228
298
|
);
|
|
229
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Returns whether the given value is in the options array
|
|
302
|
+
* @param value the value to check
|
|
303
|
+
*/
|
|
230
304
|
const isValueValid = useCallback(
|
|
231
305
|
(value: string | null): boolean => {
|
|
232
306
|
return value ? !!allOptions.find(opt => opt.value === value) : false;
|
|
@@ -234,6 +308,10 @@ export default function Combobox<M extends boolean>({
|
|
|
234
308
|
[allOptions],
|
|
235
309
|
);
|
|
236
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Returns the index of a given value in the array of visible (filtered) options
|
|
313
|
+
* @param value the option value to get the index of
|
|
314
|
+
*/
|
|
237
315
|
const getIndexOfValue = useCallback(
|
|
238
316
|
(value: string | null): number => {
|
|
239
317
|
return visibleOptions
|
|
@@ -243,6 +321,10 @@ export default function Combobox<M extends boolean>({
|
|
|
243
321
|
[visibleOptions],
|
|
244
322
|
);
|
|
245
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Returns the option value of a given index in the array of visible (filtered) options
|
|
326
|
+
* @param index the option index to get the value of
|
|
327
|
+
*/
|
|
246
328
|
const getValueAtIndex = useCallback(
|
|
247
329
|
(index: number): string | undefined => {
|
|
248
330
|
if (visibleOptions && visibleOptions.length >= index) {
|
|
@@ -253,6 +335,9 @@ export default function Combobox<M extends boolean>({
|
|
|
253
335
|
[visibleOptions],
|
|
254
336
|
);
|
|
255
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Returns the index of the active chip in the selection array
|
|
340
|
+
*/
|
|
256
341
|
const getActiveChipIndex = useCallback(
|
|
257
342
|
() =>
|
|
258
343
|
isMultiselect(selection)
|
|
@@ -269,46 +354,21 @@ export default function Combobox<M extends boolean>({
|
|
|
269
354
|
*
|
|
270
355
|
*/
|
|
271
356
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
ClearButton: clearButtonRef.current?.contains(document.activeElement),
|
|
276
|
-
Chip:
|
|
277
|
-
isMultiselect(selection) &&
|
|
278
|
-
selection.some(value =>
|
|
279
|
-
getChipRef(value)?.current?.contains(document.activeElement),
|
|
280
|
-
),
|
|
281
|
-
};
|
|
282
|
-
const getActiveChipIndex = () =>
|
|
283
|
-
isMultiselect(selection)
|
|
284
|
-
? selection.findIndex(value =>
|
|
285
|
-
getChipRef(value)?.current?.contains(document.activeElement),
|
|
286
|
-
)
|
|
287
|
-
: -1;
|
|
288
|
-
|
|
289
|
-
if (isMultiselect(selection) && isFocusOn.Chip) {
|
|
290
|
-
if (getActiveChipIndex() === 0) {
|
|
291
|
-
return 'FirstChip';
|
|
292
|
-
} else if (getActiveChipIndex() === selection.length - 1) {
|
|
293
|
-
return 'LastChip';
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return 'MiddleChip';
|
|
297
|
-
} else if (isFocusOn.Input) {
|
|
298
|
-
return 'Input';
|
|
299
|
-
} else if (isFocusOn.ClearButton) {
|
|
300
|
-
return 'ClearButton';
|
|
301
|
-
} else if (comboboxRef.current?.contains(document.activeElement)) {
|
|
302
|
-
return 'Combobox';
|
|
303
|
-
}
|
|
304
|
-
}, [getChipRef, isMultiselect, selection]);
|
|
357
|
+
const [focusedElementName, trackFocusedElement] = useState<
|
|
358
|
+
ComboboxElement | undefined
|
|
359
|
+
>();
|
|
305
360
|
|
|
306
361
|
type Direction = 'next' | 'prev' | 'first' | 'last';
|
|
307
|
-
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Updates the highlighted menu option based on the provided direction
|
|
365
|
+
* @param direction the direction to move the focus. `'next' | 'prev' | 'first' | 'last'`
|
|
366
|
+
*/
|
|
367
|
+
const updateHighlightedOption = useCallback(
|
|
308
368
|
(direction: Direction) => {
|
|
309
369
|
const optionsCount = visibleOptions?.length ?? 0;
|
|
310
370
|
const lastIndex = optionsCount - 1 > 0 ? optionsCount - 1 : 0;
|
|
311
|
-
const
|
|
371
|
+
const indexOfHighlight = getIndexOfValue(highlightedOption);
|
|
312
372
|
|
|
313
373
|
// Remove focus from chip
|
|
314
374
|
if (direction && isOpen) {
|
|
@@ -319,39 +379,39 @@ export default function Combobox<M extends boolean>({
|
|
|
319
379
|
switch (direction) {
|
|
320
380
|
case 'next': {
|
|
321
381
|
const newValue =
|
|
322
|
-
|
|
323
|
-
? getValueAtIndex(
|
|
382
|
+
indexOfHighlight + 1 < optionsCount
|
|
383
|
+
? getValueAtIndex(indexOfHighlight + 1)
|
|
324
384
|
: getValueAtIndex(0);
|
|
325
385
|
|
|
326
|
-
|
|
386
|
+
sethighlightedOption(newValue ?? null);
|
|
327
387
|
break;
|
|
328
388
|
}
|
|
329
389
|
|
|
330
390
|
case 'prev': {
|
|
331
391
|
const newValue =
|
|
332
|
-
|
|
333
|
-
? getValueAtIndex(
|
|
392
|
+
indexOfHighlight - 1 >= 0
|
|
393
|
+
? getValueAtIndex(indexOfHighlight - 1)
|
|
334
394
|
: getValueAtIndex(lastIndex);
|
|
335
395
|
|
|
336
|
-
|
|
396
|
+
sethighlightedOption(newValue ?? null);
|
|
337
397
|
break;
|
|
338
398
|
}
|
|
339
399
|
|
|
340
400
|
case 'last': {
|
|
341
401
|
const newValue = getValueAtIndex(lastIndex);
|
|
342
|
-
|
|
402
|
+
sethighlightedOption(newValue ?? null);
|
|
343
403
|
break;
|
|
344
404
|
}
|
|
345
405
|
|
|
346
406
|
case 'first':
|
|
347
407
|
default: {
|
|
348
408
|
const newValue = getValueAtIndex(0);
|
|
349
|
-
|
|
409
|
+
sethighlightedOption(newValue ?? null);
|
|
350
410
|
}
|
|
351
411
|
}
|
|
352
412
|
},
|
|
353
413
|
[
|
|
354
|
-
|
|
414
|
+
highlightedOption,
|
|
355
415
|
getIndexOfValue,
|
|
356
416
|
getValueAtIndex,
|
|
357
417
|
isOpen,
|
|
@@ -360,6 +420,11 @@ export default function Combobox<M extends boolean>({
|
|
|
360
420
|
],
|
|
361
421
|
);
|
|
362
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Updates the focused chip based on the provided direction
|
|
425
|
+
* @param direction the direction to move the focus. `'next' | 'prev' | 'first' | 'last'`
|
|
426
|
+
* @param relativeToIndex the chip index to move focus relative to
|
|
427
|
+
*/
|
|
363
428
|
const updateFocusedChip = useCallback(
|
|
364
429
|
(direction: Direction | null, relativeToIndex?: number) => {
|
|
365
430
|
if (isMultiselect(selection)) {
|
|
@@ -409,17 +474,18 @@ export default function Combobox<M extends boolean>({
|
|
|
409
474
|
[getActiveChipIndex, isMultiselect, selection],
|
|
410
475
|
);
|
|
411
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Handles an arrow key press
|
|
479
|
+
*/
|
|
412
480
|
const handleArrowKey = useCallback(
|
|
413
481
|
(direction: 'left' | 'right', event: React.KeyboardEvent<Element>) => {
|
|
414
482
|
// Remove focus from menu
|
|
415
|
-
if (direction)
|
|
416
|
-
|
|
417
|
-
const focusedElementName = getFocusedElementName();
|
|
483
|
+
if (direction) sethighlightedOption(null);
|
|
418
484
|
|
|
419
485
|
switch (direction) {
|
|
420
486
|
case 'right':
|
|
421
487
|
switch (focusedElementName) {
|
|
422
|
-
case
|
|
488
|
+
case ComboboxElement.Input: {
|
|
423
489
|
// If cursor is at the end of the input
|
|
424
490
|
if (
|
|
425
491
|
inputRef.current?.selectionEnd ===
|
|
@@ -430,21 +496,26 @@ export default function Combobox<M extends boolean>({
|
|
|
430
496
|
break;
|
|
431
497
|
}
|
|
432
498
|
|
|
433
|
-
case
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
499
|
+
case ComboboxElement.FirstChip:
|
|
500
|
+
case ComboboxElement.MiddleChip:
|
|
501
|
+
case ComboboxElement.LastChip: {
|
|
502
|
+
if (
|
|
503
|
+
focusedElementName === ComboboxElement.LastChip ||
|
|
504
|
+
// the first chip is also the last chip (i.e. only one)
|
|
505
|
+
selection?.length === 1
|
|
506
|
+
) {
|
|
507
|
+
// if focus is on last chip, go to input
|
|
508
|
+
setInputFocus(0);
|
|
509
|
+
updateFocusedChip(null);
|
|
510
|
+
event.preventDefault();
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
// First/middle chips
|
|
443
514
|
updateFocusedChip('next');
|
|
444
515
|
break;
|
|
445
516
|
}
|
|
446
517
|
|
|
447
|
-
case
|
|
518
|
+
case ComboboxElement.ClearButton:
|
|
448
519
|
default:
|
|
449
520
|
break;
|
|
450
521
|
}
|
|
@@ -452,19 +523,19 @@ export default function Combobox<M extends boolean>({
|
|
|
452
523
|
|
|
453
524
|
case 'left':
|
|
454
525
|
switch (focusedElementName) {
|
|
455
|
-
case
|
|
526
|
+
case ComboboxElement.ClearButton: {
|
|
456
527
|
event.preventDefault();
|
|
457
528
|
setInputFocus(inputRef?.current?.value.length);
|
|
458
529
|
break;
|
|
459
530
|
}
|
|
460
531
|
|
|
461
|
-
case
|
|
462
|
-
case
|
|
463
|
-
case
|
|
532
|
+
case ComboboxElement.Input:
|
|
533
|
+
case ComboboxElement.MiddleChip:
|
|
534
|
+
case ComboboxElement.LastChip: {
|
|
464
535
|
if (isMultiselect(selection)) {
|
|
465
536
|
// Break if cursor is not at the start of the input
|
|
466
537
|
if (
|
|
467
|
-
focusedElementName ===
|
|
538
|
+
focusedElementName === ComboboxElement.Input &&
|
|
468
539
|
inputRef.current?.selectionStart !== 0
|
|
469
540
|
) {
|
|
470
541
|
break;
|
|
@@ -475,7 +546,7 @@ export default function Combobox<M extends boolean>({
|
|
|
475
546
|
break;
|
|
476
547
|
}
|
|
477
548
|
|
|
478
|
-
case
|
|
549
|
+
case ComboboxElement.FirstChip:
|
|
479
550
|
default:
|
|
480
551
|
break;
|
|
481
552
|
}
|
|
@@ -486,7 +557,7 @@ export default function Combobox<M extends boolean>({
|
|
|
486
557
|
}
|
|
487
558
|
},
|
|
488
559
|
[
|
|
489
|
-
|
|
560
|
+
focusedElementName,
|
|
490
561
|
isMultiselect,
|
|
491
562
|
selection,
|
|
492
563
|
setInputFocus,
|
|
@@ -494,17 +565,18 @@ export default function Combobox<M extends boolean>({
|
|
|
494
565
|
],
|
|
495
566
|
);
|
|
496
567
|
|
|
497
|
-
//
|
|
568
|
+
// When the input value changes (or when the menu opens)
|
|
569
|
+
// Update the focused option
|
|
498
570
|
useEffect(() => {
|
|
499
571
|
if (inputValue !== prevValue) {
|
|
500
|
-
|
|
572
|
+
updateHighlightedOption('first');
|
|
501
573
|
}
|
|
502
|
-
}, [inputValue, isOpen, prevValue,
|
|
574
|
+
}, [inputValue, isOpen, prevValue, updateHighlightedOption]);
|
|
503
575
|
|
|
504
|
-
// When the focused option
|
|
576
|
+
// When the focused option changes, update the menu scroll if necessary
|
|
505
577
|
useEffect(() => {
|
|
506
|
-
if (
|
|
507
|
-
const focusedElementRef = getOptionRef(
|
|
578
|
+
if (highlightedOption) {
|
|
579
|
+
const focusedElementRef = getOptionRef(highlightedOption);
|
|
508
580
|
|
|
509
581
|
if (focusedElementRef && focusedElementRef.current && menuRef.current) {
|
|
510
582
|
const { offsetTop: optionTop } = focusedElementRef.current;
|
|
@@ -516,12 +588,14 @@ export default function Combobox<M extends boolean>({
|
|
|
516
588
|
}
|
|
517
589
|
}
|
|
518
590
|
}
|
|
519
|
-
}, [
|
|
591
|
+
}, [highlightedOption, getOptionRef]);
|
|
520
592
|
|
|
521
593
|
/**
|
|
522
|
-
*
|
|
523
594
|
* Rendering
|
|
524
|
-
|
|
595
|
+
*/
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Callback to render the children as <InternalComboboxOption> elements
|
|
525
599
|
*/
|
|
526
600
|
const renderInternalOptions = useCallback(
|
|
527
601
|
(_children: React.ReactNode) => {
|
|
@@ -529,17 +603,17 @@ export default function Combobox<M extends boolean>({
|
|
|
529
603
|
if (isComponentType(child, 'ComboboxOption')) {
|
|
530
604
|
const { value, displayName } = getNameAndValue(child.props);
|
|
531
605
|
|
|
532
|
-
if (
|
|
533
|
-
const { className, glyph } = child.props;
|
|
606
|
+
if (shouldOptionBeVisible(value)) {
|
|
607
|
+
const { className, glyph, disabled } = child.props;
|
|
534
608
|
const index = allOptions.findIndex(opt => opt.value === value);
|
|
535
609
|
|
|
536
|
-
const isFocused =
|
|
610
|
+
const isFocused = highlightedOption === value;
|
|
537
611
|
const isSelected = isMultiselect(selection)
|
|
538
612
|
? selection.includes(value)
|
|
539
613
|
: selection === value;
|
|
540
614
|
|
|
541
615
|
const setSelected = () => {
|
|
542
|
-
|
|
616
|
+
sethighlightedOption(value);
|
|
543
617
|
updateSelection(value);
|
|
544
618
|
setInputFocus();
|
|
545
619
|
|
|
@@ -556,6 +630,7 @@ export default function Combobox<M extends boolean>({
|
|
|
556
630
|
displayName={displayName}
|
|
557
631
|
isFocused={isFocused}
|
|
558
632
|
isSelected={isSelected}
|
|
633
|
+
disabled={disabled}
|
|
559
634
|
setSelected={setSelected}
|
|
560
635
|
glyph={glyph}
|
|
561
636
|
className={className}
|
|
@@ -582,34 +657,46 @@ export default function Combobox<M extends boolean>({
|
|
|
582
657
|
},
|
|
583
658
|
[
|
|
584
659
|
allOptions,
|
|
585
|
-
|
|
660
|
+
highlightedOption,
|
|
586
661
|
getOptionRef,
|
|
587
662
|
isMultiselect,
|
|
588
|
-
|
|
663
|
+
shouldOptionBeVisible,
|
|
589
664
|
selection,
|
|
590
665
|
setInputFocus,
|
|
591
666
|
updateSelection,
|
|
592
667
|
],
|
|
593
668
|
);
|
|
594
669
|
|
|
595
|
-
|
|
670
|
+
/**
|
|
671
|
+
* The rendered JSX elements for the options
|
|
672
|
+
*/
|
|
673
|
+
const renderedOptionsJSX = useMemo(
|
|
596
674
|
() => renderInternalOptions(children),
|
|
597
675
|
[children, renderInternalOptions],
|
|
598
676
|
);
|
|
599
677
|
|
|
678
|
+
/**
|
|
679
|
+
* The rendered JSX for the selection Chips
|
|
680
|
+
*/
|
|
600
681
|
const renderedChips = useMemo(() => {
|
|
601
682
|
if (isMultiselect(selection)) {
|
|
602
683
|
return selection.filter(isValueValid).map((value, index) => {
|
|
603
|
-
const displayName = getDisplayNameForValue(value);
|
|
684
|
+
const displayName = getDisplayNameForValue(value, allOptions);
|
|
685
|
+
const isFocused = focusedChip === value;
|
|
686
|
+
const chipRef = getChipRef(value);
|
|
687
|
+
const isLastChip = index >= selection.length - 1;
|
|
604
688
|
|
|
605
689
|
const onRemove = () => {
|
|
606
|
-
|
|
690
|
+
if (isLastChip) {
|
|
691
|
+
// Focus the input if this is the last chip in the set
|
|
692
|
+
setInputFocus();
|
|
693
|
+
updateFocusedChip(null);
|
|
694
|
+
} else {
|
|
695
|
+
updateFocusedChip('next', index);
|
|
696
|
+
}
|
|
607
697
|
updateSelection(value);
|
|
608
698
|
};
|
|
609
699
|
|
|
610
|
-
const isFocused = focusedChip === value;
|
|
611
|
-
const chipRef = getChipRef(value);
|
|
612
|
-
|
|
613
700
|
const onFocus = () => {
|
|
614
701
|
setFocusedChip(value);
|
|
615
702
|
};
|
|
@@ -630,13 +717,17 @@ export default function Combobox<M extends boolean>({
|
|
|
630
717
|
isMultiselect,
|
|
631
718
|
selection,
|
|
632
719
|
isValueValid,
|
|
633
|
-
|
|
720
|
+
allOptions,
|
|
634
721
|
focusedChip,
|
|
635
722
|
getChipRef,
|
|
636
|
-
updateFocusedChip,
|
|
637
723
|
updateSelection,
|
|
724
|
+
setInputFocus,
|
|
725
|
+
updateFocusedChip,
|
|
638
726
|
]);
|
|
639
727
|
|
|
728
|
+
/**
|
|
729
|
+
* The rendered JSX for the input icons (clear, warn & caret)
|
|
730
|
+
*/
|
|
640
731
|
const renderedInputIcons = useMemo(() => {
|
|
641
732
|
const handleClearButtonClick = (
|
|
642
733
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
@@ -661,7 +752,7 @@ export default function Combobox<M extends boolean>({
|
|
|
661
752
|
ref={clearButtonRef}
|
|
662
753
|
onClick={handleClearButtonClick}
|
|
663
754
|
onFocus={handleClearButtonFocus}
|
|
664
|
-
className={
|
|
755
|
+
className={cx(clearButtonStyle, clearButtonFocusOverrideStyles)}
|
|
665
756
|
>
|
|
666
757
|
<Icon glyph="XWithCircle" />
|
|
667
758
|
</IconButton>
|
|
@@ -684,7 +775,9 @@ export default function Combobox<M extends boolean>({
|
|
|
684
775
|
isOpen,
|
|
685
776
|
]);
|
|
686
777
|
|
|
687
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Flag to determine whether the rendered options have icons
|
|
780
|
+
*/
|
|
688
781
|
const withIcons = useMemo(
|
|
689
782
|
() => allOptions.some(opt => opt.hasGlyph),
|
|
690
783
|
[allOptions],
|
|
@@ -697,6 +790,7 @@ export default function Combobox<M extends boolean>({
|
|
|
697
790
|
*/
|
|
698
791
|
|
|
699
792
|
const onCloseMenu = useCallback(() => {
|
|
793
|
+
// Single select, and no change to selection
|
|
700
794
|
if (!isMultiselect(selection) && selection === prevSelection) {
|
|
701
795
|
const exactMatchedOption = visibleOptions.find(
|
|
702
796
|
option =>
|
|
@@ -710,12 +804,15 @@ export default function Combobox<M extends boolean>({
|
|
|
710
804
|
} else {
|
|
711
805
|
// Revert the value to the previous selection
|
|
712
806
|
const displayName =
|
|
713
|
-
getDisplayNameForValue(
|
|
807
|
+
getDisplayNameForValue(
|
|
808
|
+
selection as SelectValueType<false>,
|
|
809
|
+
allOptions,
|
|
810
|
+
) ?? '';
|
|
714
811
|
setInputValue(displayName);
|
|
715
812
|
}
|
|
716
813
|
}
|
|
717
814
|
}, [
|
|
718
|
-
|
|
815
|
+
allOptions,
|
|
719
816
|
inputValue,
|
|
720
817
|
isMultiselect,
|
|
721
818
|
prevSelection,
|
|
@@ -728,20 +825,23 @@ export default function Combobox<M extends boolean>({
|
|
|
728
825
|
if (doesSelectionExist) {
|
|
729
826
|
if (isMultiselect(selection)) {
|
|
730
827
|
// Scroll the wrapper to the end. No effect if not `overflow="scroll-x"`
|
|
731
|
-
|
|
828
|
+
scrollInputToEnd();
|
|
732
829
|
} else if (!isMultiselect(selection)) {
|
|
733
830
|
// Update the text input
|
|
734
831
|
const displayName =
|
|
735
|
-
getDisplayNameForValue(
|
|
832
|
+
getDisplayNameForValue(
|
|
833
|
+
selection as SelectValueType<false>,
|
|
834
|
+
allOptions,
|
|
835
|
+
) ?? '';
|
|
736
836
|
setInputValue(displayName);
|
|
737
837
|
closeMenu();
|
|
738
838
|
}
|
|
739
839
|
} else {
|
|
740
840
|
setInputValue('');
|
|
741
841
|
}
|
|
742
|
-
}, [doesSelectionExist,
|
|
842
|
+
}, [doesSelectionExist, allOptions, isMultiselect, selection]);
|
|
743
843
|
|
|
744
|
-
// Set initialValue
|
|
844
|
+
// Set the initialValue
|
|
745
845
|
useEffect(() => {
|
|
746
846
|
if (initialValue) {
|
|
747
847
|
if (isArray(initialValue)) {
|
|
@@ -787,27 +887,34 @@ export default function Combobox<M extends boolean>({
|
|
|
787
887
|
|
|
788
888
|
// when the menu closes, update the value if needed
|
|
789
889
|
useEffect(() => {
|
|
790
|
-
if (!isOpen &&
|
|
890
|
+
if (!isOpen && wasOpen) {
|
|
791
891
|
onCloseMenu();
|
|
792
892
|
}
|
|
793
|
-
}, [isOpen,
|
|
893
|
+
}, [isOpen, wasOpen, onCloseMenu]);
|
|
794
894
|
|
|
795
895
|
/**
|
|
796
896
|
*
|
|
797
897
|
* Menu management
|
|
798
898
|
*
|
|
799
899
|
*/
|
|
800
|
-
const closeMenu = () => setOpen(false);
|
|
801
|
-
const openMenu = () => setOpen(true);
|
|
802
900
|
|
|
803
901
|
const [menuWidth, setMenuWidth] = useState(0);
|
|
902
|
+
|
|
903
|
+
// When the menu opens, or the selection changes, or the focused option changes
|
|
904
|
+
// update the menu width
|
|
804
905
|
useEffect(() => {
|
|
805
906
|
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
806
|
-
}, [comboboxRef, isOpen,
|
|
907
|
+
}, [comboboxRef, isOpen, highlightedOption, selection]);
|
|
908
|
+
|
|
909
|
+
// Handler fired when the manu has finished transitioning in/out
|
|
807
910
|
const handleTransitionEnd = () => {
|
|
808
911
|
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
809
912
|
};
|
|
810
913
|
|
|
914
|
+
/**
|
|
915
|
+
* The rendered menu JSX contents
|
|
916
|
+
* Includes error, empty, search and default states
|
|
917
|
+
*/
|
|
811
918
|
const renderedMenuContents = useMemo((): JSX.Element => {
|
|
812
919
|
switch (searchState) {
|
|
813
920
|
case 'loading': {
|
|
@@ -834,51 +941,23 @@ export default function Combobox<M extends boolean>({
|
|
|
834
941
|
|
|
835
942
|
case 'unset':
|
|
836
943
|
default: {
|
|
837
|
-
if (
|
|
838
|
-
return <ul className={menuList}>{
|
|
944
|
+
if (renderedOptionsJSX && renderedOptionsJSX.length > 0) {
|
|
945
|
+
return <ul className={menuList}>{renderedOptionsJSX}</ul>;
|
|
839
946
|
}
|
|
840
947
|
|
|
841
948
|
return <span className={menuMessage}>{searchEmptyMessage}</span>;
|
|
842
949
|
}
|
|
843
950
|
}
|
|
844
951
|
}, [
|
|
845
|
-
|
|
952
|
+
renderedOptionsJSX,
|
|
846
953
|
searchEmptyMessage,
|
|
847
954
|
searchErrorMessage,
|
|
848
955
|
searchLoadingMessage,
|
|
849
956
|
searchState,
|
|
850
957
|
]);
|
|
851
958
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
// Set the max height of the menu
|
|
855
|
-
const maxHeight = useMemo(() => {
|
|
856
|
-
// TODO - consolidate this hook with Select/ListMenu
|
|
857
|
-
const maxMenuHeight = 274;
|
|
858
|
-
const menuMargin = 8;
|
|
859
|
-
|
|
860
|
-
if (viewportSize && comboboxRef.current && menuRef.current) {
|
|
861
|
-
const { top: triggerTop, bottom: triggerBottom } =
|
|
862
|
-
comboboxRef.current.getBoundingClientRect();
|
|
863
|
-
|
|
864
|
-
// Find out how much space is available above or below the trigger
|
|
865
|
-
const safeSpace = Math.max(
|
|
866
|
-
viewportSize.height - triggerBottom,
|
|
867
|
-
triggerTop,
|
|
868
|
-
);
|
|
869
|
-
|
|
870
|
-
// if there's more than enough space, set to maxMenuHeight
|
|
871
|
-
// otherwise fill the space available
|
|
872
|
-
return Math.min(maxMenuHeight, safeSpace - menuMargin);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
return maxMenuHeight;
|
|
876
|
-
}, [viewportSize, comboboxRef, menuRef]);
|
|
877
|
-
|
|
878
|
-
// Scroll the menu when the focus changes
|
|
879
|
-
useEffect(() => {
|
|
880
|
-
// get the focused option
|
|
881
|
-
}, [focusedOption]);
|
|
959
|
+
/** The max height of the menu element */
|
|
960
|
+
const maxHeight = Math.min(256, useAvailableSpace(comboboxRef));
|
|
882
961
|
|
|
883
962
|
/**
|
|
884
963
|
*
|
|
@@ -894,7 +973,9 @@ export default function Combobox<M extends boolean>({
|
|
|
894
973
|
};
|
|
895
974
|
|
|
896
975
|
// Set focus to the input element on click
|
|
897
|
-
const
|
|
976
|
+
const handleComboboxClick = (e: React.MouseEvent) => {
|
|
977
|
+
// If we clicked the wrapper, not the input itself.
|
|
978
|
+
// (Focus is set automatically if the click is on the input)
|
|
898
979
|
if (e.target !== inputRef.current) {
|
|
899
980
|
let cursorPos = 0;
|
|
900
981
|
|
|
@@ -909,10 +990,12 @@ export default function Combobox<M extends boolean>({
|
|
|
909
990
|
}
|
|
910
991
|
};
|
|
911
992
|
|
|
912
|
-
// Fired
|
|
913
|
-
|
|
914
|
-
|
|
993
|
+
// Fired whenever the wrapper gains focus,
|
|
994
|
+
// and any time the focus within changes
|
|
995
|
+
const handleComboboxFocus = (e: React.FocusEvent) => {
|
|
996
|
+
scrollInputToEnd();
|
|
915
997
|
openMenu();
|
|
998
|
+
trackFocusedElement(getNameFromElement(e.target));
|
|
916
999
|
};
|
|
917
1000
|
|
|
918
1001
|
// Fired onChange
|
|
@@ -925,7 +1008,7 @@ export default function Combobox<M extends boolean>({
|
|
|
925
1008
|
};
|
|
926
1009
|
|
|
927
1010
|
const handleClearButtonFocus = () => {
|
|
928
|
-
|
|
1011
|
+
sethighlightedOption(null);
|
|
929
1012
|
};
|
|
930
1013
|
|
|
931
1014
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
@@ -936,6 +1019,7 @@ export default function Combobox<M extends boolean>({
|
|
|
936
1019
|
|
|
937
1020
|
const isFocusInComponent = isFocusOnCombobox || isFocusInMenu;
|
|
938
1021
|
|
|
1022
|
+
// Only run if the focus is in the component
|
|
939
1023
|
if (isFocusInComponent) {
|
|
940
1024
|
// No support for modifiers yet
|
|
941
1025
|
// TODO - Handle support for multiple chip selection
|
|
@@ -943,15 +1027,13 @@ export default function Combobox<M extends boolean>({
|
|
|
943
1027
|
return;
|
|
944
1028
|
}
|
|
945
1029
|
|
|
946
|
-
const focusedElement = getFocusedElementName();
|
|
947
|
-
|
|
948
1030
|
switch (event.keyCode) {
|
|
949
1031
|
case keyMap.Tab: {
|
|
950
|
-
switch (
|
|
1032
|
+
switch (focusedElementName) {
|
|
951
1033
|
case 'Input': {
|
|
952
1034
|
if (!doesSelectionExist) {
|
|
953
1035
|
closeMenu();
|
|
954
|
-
|
|
1036
|
+
updateHighlightedOption('first');
|
|
955
1037
|
updateFocusedChip(null);
|
|
956
1038
|
}
|
|
957
1039
|
// else use default behavior
|
|
@@ -980,21 +1062,25 @@ export default function Combobox<M extends boolean>({
|
|
|
980
1062
|
|
|
981
1063
|
case keyMap.Escape: {
|
|
982
1064
|
closeMenu();
|
|
983
|
-
|
|
1065
|
+
updateHighlightedOption('first');
|
|
984
1066
|
break;
|
|
985
1067
|
}
|
|
986
1068
|
|
|
987
1069
|
case keyMap.Enter: {
|
|
1070
|
+
// Select the highlighed option iff
|
|
1071
|
+
// the menu is open
|
|
1072
|
+
// we're focused on input element,
|
|
1073
|
+
// and the highlighted option is not disabled
|
|
988
1074
|
if (
|
|
989
|
-
// Focused on input element
|
|
990
|
-
document.activeElement === inputRef.current &&
|
|
991
1075
|
isOpen &&
|
|
992
|
-
|
|
1076
|
+
focusedElementName === ComboboxElement.Input &&
|
|
1077
|
+
!isNull(highlightedOption) &&
|
|
1078
|
+
!isOptionDisabled(highlightedOption)
|
|
993
1079
|
) {
|
|
994
|
-
updateSelection(
|
|
1080
|
+
updateSelection(highlightedOption);
|
|
995
1081
|
} else if (
|
|
996
1082
|
// Focused on clear button
|
|
997
|
-
|
|
1083
|
+
focusedElementName === ComboboxElement.ClearButton
|
|
998
1084
|
) {
|
|
999
1085
|
updateSelection(null);
|
|
1000
1086
|
setInputFocus();
|
|
@@ -1003,16 +1089,18 @@ export default function Combobox<M extends boolean>({
|
|
|
1003
1089
|
}
|
|
1004
1090
|
|
|
1005
1091
|
case keyMap.Backspace: {
|
|
1006
|
-
// Backspace key focuses last chip
|
|
1007
|
-
//
|
|
1008
|
-
if (
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1092
|
+
// Backspace key focuses last chip if the input is focused
|
|
1093
|
+
// Note: Chip removal behavior is handled in `onRemove` defined in `renderChips`
|
|
1094
|
+
if (isMultiselect(selection)) {
|
|
1095
|
+
if (
|
|
1096
|
+
focusedElementName === 'Input' &&
|
|
1097
|
+
inputRef.current?.selectionStart === 0
|
|
1098
|
+
) {
|
|
1099
|
+
updateFocusedChip('last');
|
|
1100
|
+
}
|
|
1015
1101
|
}
|
|
1102
|
+
// Open the menu regardless
|
|
1103
|
+
openMenu();
|
|
1016
1104
|
break;
|
|
1017
1105
|
}
|
|
1018
1106
|
|
|
@@ -1020,9 +1108,11 @@ export default function Combobox<M extends boolean>({
|
|
|
1020
1108
|
if (isOpen) {
|
|
1021
1109
|
// Prevent the page from scrolling
|
|
1022
1110
|
event.preventDefault();
|
|
1111
|
+
// only change option if the menu is already open
|
|
1112
|
+
updateHighlightedOption('next');
|
|
1113
|
+
} else {
|
|
1114
|
+
openMenu();
|
|
1023
1115
|
}
|
|
1024
|
-
openMenu();
|
|
1025
|
-
updateFocusedOption('next');
|
|
1026
1116
|
break;
|
|
1027
1117
|
}
|
|
1028
1118
|
|
|
@@ -1030,8 +1120,11 @@ export default function Combobox<M extends boolean>({
|
|
|
1030
1120
|
if (isOpen) {
|
|
1031
1121
|
// Prevent the page from scrolling
|
|
1032
1122
|
event.preventDefault();
|
|
1123
|
+
// only change option if the menu is already open
|
|
1124
|
+
updateHighlightedOption('prev');
|
|
1125
|
+
} else {
|
|
1126
|
+
openMenu();
|
|
1033
1127
|
}
|
|
1034
|
-
updateFocusedOption('prev');
|
|
1035
1128
|
break;
|
|
1036
1129
|
}
|
|
1037
1130
|
|
|
@@ -1059,19 +1152,44 @@ export default function Combobox<M extends boolean>({
|
|
|
1059
1152
|
* Global Event Handler
|
|
1060
1153
|
*
|
|
1061
1154
|
*/
|
|
1062
|
-
// Global backdrop click handler
|
|
1063
|
-
const handleBackdropClick = ({ target }: MouseEvent) => {
|
|
1064
|
-
const isChildFocused =
|
|
1065
|
-
menuRef.current?.contains(target as Node) ||
|
|
1066
|
-
comboboxRef.current?.contains(target as Node) ||
|
|
1067
|
-
false;
|
|
1068
|
-
|
|
1069
|
-
if (!isChildFocused) {
|
|
1070
|
-
setOpen(false);
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
1155
|
|
|
1074
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* We add two event handlers to the document to handle the backdrop click behavior.
|
|
1158
|
+
* Intended behavior is to close the menu, and keep focus on the Combobox.
|
|
1159
|
+
* No other click event handlers should fire on backdrop click
|
|
1160
|
+
*
|
|
1161
|
+
* 1. Mousedown event fires
|
|
1162
|
+
* 2. We prevent `mousedown`'s default behavior, to prevent focus from being applied to the body (or other target)
|
|
1163
|
+
* 3. Click event fires
|
|
1164
|
+
* 4. We handle this event on _capture_, and stop propagation before the `click` event propagates all the way to any other element.
|
|
1165
|
+
* This ensures that even if we click on a button, that handler is not fired
|
|
1166
|
+
* 5. Then we call `closeMenu`, setting `isOpen = false`, and rerender the component
|
|
1167
|
+
*/
|
|
1168
|
+
useEventListener(
|
|
1169
|
+
'mousedown',
|
|
1170
|
+
(mousedown: MouseEvent) => {
|
|
1171
|
+
if (!doesComponentContainEventTarget(mousedown)) {
|
|
1172
|
+
mousedown.preventDefault(); // Prevent focus from being applied to body
|
|
1173
|
+
mousedown.stopPropagation(); // Stop any other mousedown events from firing
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
enabled: isOpen,
|
|
1178
|
+
},
|
|
1179
|
+
);
|
|
1180
|
+
useEventListener(
|
|
1181
|
+
'click',
|
|
1182
|
+
(click: MouseEvent) => {
|
|
1183
|
+
if (!doesComponentContainEventTarget(click)) {
|
|
1184
|
+
click.stopPropagation(); // Stop any other click events from firing
|
|
1185
|
+
closeMenu();
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
options: { capture: true },
|
|
1190
|
+
enabled: isOpen,
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1075
1193
|
|
|
1076
1194
|
const popoverProps = {
|
|
1077
1195
|
popoverZIndex,
|
|
@@ -1107,64 +1225,69 @@ export default function Combobox<M extends boolean>({
|
|
|
1107
1225
|
>
|
|
1108
1226
|
<div>
|
|
1109
1227
|
{label && (
|
|
1110
|
-
<Label
|
|
1228
|
+
<Label
|
|
1229
|
+
id={labelId}
|
|
1230
|
+
htmlFor={inputId}
|
|
1231
|
+
className={_tempLabelDescriptionOverrideStyle}
|
|
1232
|
+
>
|
|
1111
1233
|
{label}
|
|
1112
1234
|
</Label>
|
|
1113
1235
|
)}
|
|
1114
|
-
{description &&
|
|
1236
|
+
{description && (
|
|
1237
|
+
<Description className={_tempLabelDescriptionOverrideStyle}>
|
|
1238
|
+
{description}
|
|
1239
|
+
</Description>
|
|
1240
|
+
)}
|
|
1115
1241
|
</div>
|
|
1116
1242
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1243
|
+
{/* Disable eslint: onClick sets focus. Key events would already have focus */}
|
|
1244
|
+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
|
1245
|
+
<div
|
|
1246
|
+
ref={comboboxRef}
|
|
1247
|
+
role="combobox"
|
|
1248
|
+
aria-expanded={isOpen}
|
|
1249
|
+
aria-controls={menuId}
|
|
1250
|
+
aria-owns={menuId}
|
|
1251
|
+
tabIndex={-1}
|
|
1252
|
+
className={cx(comboboxStyle, {
|
|
1253
|
+
[comboboxFocusStyle]: focusedElementName === ComboboxElement.Input,
|
|
1254
|
+
})}
|
|
1255
|
+
onMouseDown={handleInputWrapperMousedown}
|
|
1256
|
+
onClick={handleComboboxClick}
|
|
1257
|
+
onFocus={handleComboboxFocus}
|
|
1258
|
+
onKeyDown={handleKeyDown}
|
|
1259
|
+
onTransitionEnd={handleTransitionEnd}
|
|
1260
|
+
data-disabled={disabled}
|
|
1261
|
+
data-state={state}
|
|
1121
1262
|
>
|
|
1122
|
-
{/* Disable eslint: onClick sets focus. Key events would already have focus */}
|
|
1123
|
-
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
|
1124
1263
|
<div
|
|
1125
|
-
ref={
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
onClick={handleInputWrapperClick}
|
|
1134
|
-
onFocus={handleInputWrapperFocus}
|
|
1135
|
-
onKeyDown={handleKeyDown}
|
|
1136
|
-
onTransitionEnd={handleTransitionEnd}
|
|
1137
|
-
data-disabled={disabled}
|
|
1138
|
-
data-state={state}
|
|
1264
|
+
ref={inputWrapperRef}
|
|
1265
|
+
className={inputWrapperStyle({
|
|
1266
|
+
overflow,
|
|
1267
|
+
isOpen,
|
|
1268
|
+
selection,
|
|
1269
|
+
size,
|
|
1270
|
+
value: inputValue,
|
|
1271
|
+
})}
|
|
1139
1272
|
>
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
{
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
ref={inputRef}
|
|
1156
|
-
id={inputId}
|
|
1157
|
-
className={inputElementStyle}
|
|
1158
|
-
placeholder={placeholderValue}
|
|
1159
|
-
disabled={disabled ?? undefined}
|
|
1160
|
-
onChange={handleInputChange}
|
|
1161
|
-
value={inputValue}
|
|
1162
|
-
autoComplete="off"
|
|
1163
|
-
/>
|
|
1164
|
-
</div>
|
|
1165
|
-
{renderedInputIcons}
|
|
1273
|
+
{renderedChips}
|
|
1274
|
+
<input
|
|
1275
|
+
aria-label={ariaLabel ?? label}
|
|
1276
|
+
aria-autocomplete="list"
|
|
1277
|
+
aria-controls={menuId}
|
|
1278
|
+
aria-labelledby={labelId}
|
|
1279
|
+
ref={inputRef}
|
|
1280
|
+
id={inputId}
|
|
1281
|
+
className={inputElementStyle}
|
|
1282
|
+
placeholder={placeholderValue}
|
|
1283
|
+
disabled={disabled ?? undefined}
|
|
1284
|
+
onChange={handleInputChange}
|
|
1285
|
+
value={inputValue}
|
|
1286
|
+
autoComplete="off"
|
|
1287
|
+
/>
|
|
1166
1288
|
</div>
|
|
1167
|
-
|
|
1289
|
+
{renderedInputIcons}
|
|
1290
|
+
</div>
|
|
1168
1291
|
|
|
1169
1292
|
{state === 'error' && errorMessage && (
|
|
1170
1293
|
<div className={errorMessageStyle}>{errorMessage}</div>
|
|
@@ -1198,4 +1321,61 @@ export default function Combobox<M extends boolean>({
|
|
|
1198
1321
|
</div>
|
|
1199
1322
|
</ComboboxContext.Provider>
|
|
1200
1323
|
);
|
|
1324
|
+
|
|
1325
|
+
// Closure-dependant utils
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Returns whether the event target is a Combobox element
|
|
1329
|
+
*/
|
|
1330
|
+
function doesComponentContainEventTarget({ target }: MouseEvent): boolean {
|
|
1331
|
+
return (
|
|
1332
|
+
menuRef.current?.contains(target as Node) ||
|
|
1333
|
+
comboboxRef.current?.contains(target as Node) ||
|
|
1334
|
+
false
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Scrolls the combobox to the far right.
|
|
1340
|
+
* Used when `overflow == 'scroll-x'`.
|
|
1341
|
+
* Has no effect otherwise
|
|
1342
|
+
*/
|
|
1343
|
+
function scrollInputToEnd() {
|
|
1344
|
+
if (inputWrapperRef && inputWrapperRef.current) {
|
|
1345
|
+
// TODO - consider converting to .scrollTo(). This is not yet suppoted in IE or jsdom
|
|
1346
|
+
inputWrapperRef.current.scrollLeft = inputWrapperRef.current.scrollWidth;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Returns the provided element as a ComboboxElement string
|
|
1352
|
+
*/
|
|
1353
|
+
function getNameFromElement(
|
|
1354
|
+
element?: Element | null,
|
|
1355
|
+
): ComboboxElement | undefined {
|
|
1356
|
+
if (!element) return;
|
|
1357
|
+
if (inputRef.current?.contains(element)) return ComboboxElement.Input;
|
|
1358
|
+
if (clearButtonRef.current?.contains(element))
|
|
1359
|
+
return ComboboxElement.ClearButton;
|
|
1360
|
+
|
|
1361
|
+
const activeChipIndex = isMultiselect(selection)
|
|
1362
|
+
? selection.findIndex(value =>
|
|
1363
|
+
getChipRef(value)?.current?.contains(element),
|
|
1364
|
+
)
|
|
1365
|
+
: -1;
|
|
1366
|
+
|
|
1367
|
+
if (isMultiselect(selection)) {
|
|
1368
|
+
if (activeChipIndex === 0) return ComboboxElement.FirstChip;
|
|
1369
|
+
if (activeChipIndex === selection.length - 1)
|
|
1370
|
+
return ComboboxElement.LastChip;
|
|
1371
|
+
if (activeChipIndex > 0) return ComboboxElement.MiddleChip;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (menuRef.current?.contains(element)) return ComboboxElement.Menu;
|
|
1375
|
+
if (comboboxRef.current?.contains(element)) return ComboboxElement.Combobox;
|
|
1376
|
+
}
|
|
1201
1377
|
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Why'd you have to go and make things so complicated?
|
|
1380
|
+
* - Avril; and also me to myself about this component
|
|
1381
|
+
*/
|