@leafygreen-ui/combobox 1.0.2 → 1.2.0
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 +65 -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 +3 -4
- 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 +22 -12
- package/src/Chip.tsx +16 -9
- package/src/Combobox.spec.tsx +336 -164
- package/src/Combobox.story.tsx +274 -248
- package/src/Combobox.styles.ts +94 -24
- package/src/Combobox.tsx +456 -279
- package/src/Combobox.types.ts +46 -8
- package/src/ComboboxContext.tsx +2 -2
- package/src/ComboboxOption.tsx +36 -11
- 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 -3977
- 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
|
-
|
|
228
|
-
|
|
292
|
+
/**
|
|
293
|
+
* The array of visible options objects
|
|
294
|
+
*/
|
|
295
|
+
const visibleOptions: Array<OptionObject> = useMemo(
|
|
296
|
+
() => allOptions.filter(shouldOptionBeVisible),
|
|
297
|
+
[allOptions, shouldOptionBeVisible],
|
|
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,36 +565,37 @@ 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;
|
|
511
|
-
const {
|
|
512
|
-
|
|
513
|
-
offsetHeight: menuHeight,
|
|
514
|
-
} = menuRef.current;
|
|
583
|
+
const { scrollTop: menuScroll, offsetHeight: menuHeight } =
|
|
584
|
+
menuRef.current;
|
|
515
585
|
|
|
516
586
|
if (optionTop > menuHeight || optionTop < menuScroll) {
|
|
517
587
|
menuRef.current.scrollTop = optionTop;
|
|
518
588
|
}
|
|
519
589
|
}
|
|
520
590
|
}
|
|
521
|
-
}, [
|
|
591
|
+
}, [highlightedOption, getOptionRef]);
|
|
522
592
|
|
|
523
593
|
/**
|
|
524
|
-
*
|
|
525
594
|
* Rendering
|
|
526
|
-
|
|
595
|
+
*/
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Callback to render the children as <InternalComboboxOption> elements
|
|
527
599
|
*/
|
|
528
600
|
const renderInternalOptions = useCallback(
|
|
529
601
|
(_children: React.ReactNode) => {
|
|
@@ -531,17 +603,17 @@ export default function Combobox<M extends boolean>({
|
|
|
531
603
|
if (isComponentType(child, 'ComboboxOption')) {
|
|
532
604
|
const { value, displayName } = getNameAndValue(child.props);
|
|
533
605
|
|
|
534
|
-
if (
|
|
535
|
-
const { className, glyph } = child.props;
|
|
606
|
+
if (shouldOptionBeVisible(value)) {
|
|
607
|
+
const { className, glyph, disabled } = child.props;
|
|
536
608
|
const index = allOptions.findIndex(opt => opt.value === value);
|
|
537
609
|
|
|
538
|
-
const isFocused =
|
|
610
|
+
const isFocused = highlightedOption === value;
|
|
539
611
|
const isSelected = isMultiselect(selection)
|
|
540
612
|
? selection.includes(value)
|
|
541
613
|
: selection === value;
|
|
542
614
|
|
|
543
615
|
const setSelected = () => {
|
|
544
|
-
|
|
616
|
+
sethighlightedOption(value);
|
|
545
617
|
updateSelection(value);
|
|
546
618
|
setInputFocus();
|
|
547
619
|
|
|
@@ -558,6 +630,7 @@ export default function Combobox<M extends boolean>({
|
|
|
558
630
|
displayName={displayName}
|
|
559
631
|
isFocused={isFocused}
|
|
560
632
|
isSelected={isSelected}
|
|
633
|
+
disabled={disabled}
|
|
561
634
|
setSelected={setSelected}
|
|
562
635
|
glyph={glyph}
|
|
563
636
|
className={className}
|
|
@@ -584,34 +657,46 @@ export default function Combobox<M extends boolean>({
|
|
|
584
657
|
},
|
|
585
658
|
[
|
|
586
659
|
allOptions,
|
|
587
|
-
|
|
660
|
+
highlightedOption,
|
|
588
661
|
getOptionRef,
|
|
589
662
|
isMultiselect,
|
|
590
|
-
|
|
663
|
+
shouldOptionBeVisible,
|
|
591
664
|
selection,
|
|
592
665
|
setInputFocus,
|
|
593
666
|
updateSelection,
|
|
594
667
|
],
|
|
595
668
|
);
|
|
596
669
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
670
|
+
/**
|
|
671
|
+
* The rendered JSX elements for the options
|
|
672
|
+
*/
|
|
673
|
+
const renderedOptionsJSX = useMemo(
|
|
674
|
+
() => renderInternalOptions(children),
|
|
675
|
+
[children, renderInternalOptions],
|
|
676
|
+
);
|
|
601
677
|
|
|
678
|
+
/**
|
|
679
|
+
* The rendered JSX for the selection Chips
|
|
680
|
+
*/
|
|
602
681
|
const renderedChips = useMemo(() => {
|
|
603
682
|
if (isMultiselect(selection)) {
|
|
604
683
|
return selection.filter(isValueValid).map((value, index) => {
|
|
605
|
-
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;
|
|
606
688
|
|
|
607
689
|
const onRemove = () => {
|
|
608
|
-
|
|
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
|
+
}
|
|
609
697
|
updateSelection(value);
|
|
610
698
|
};
|
|
611
699
|
|
|
612
|
-
const isFocused = focusedChip === value;
|
|
613
|
-
const chipRef = getChipRef(value);
|
|
614
|
-
|
|
615
700
|
const onFocus = () => {
|
|
616
701
|
setFocusedChip(value);
|
|
617
702
|
};
|
|
@@ -632,13 +717,17 @@ export default function Combobox<M extends boolean>({
|
|
|
632
717
|
isMultiselect,
|
|
633
718
|
selection,
|
|
634
719
|
isValueValid,
|
|
635
|
-
|
|
720
|
+
allOptions,
|
|
636
721
|
focusedChip,
|
|
637
722
|
getChipRef,
|
|
638
|
-
updateFocusedChip,
|
|
639
723
|
updateSelection,
|
|
724
|
+
setInputFocus,
|
|
725
|
+
updateFocusedChip,
|
|
640
726
|
]);
|
|
641
727
|
|
|
728
|
+
/**
|
|
729
|
+
* The rendered JSX for the input icons (clear, warn & caret)
|
|
730
|
+
*/
|
|
642
731
|
const renderedInputIcons = useMemo(() => {
|
|
643
732
|
const handleClearButtonClick = (
|
|
644
733
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
@@ -663,7 +752,7 @@ export default function Combobox<M extends boolean>({
|
|
|
663
752
|
ref={clearButtonRef}
|
|
664
753
|
onClick={handleClearButtonClick}
|
|
665
754
|
onFocus={handleClearButtonFocus}
|
|
666
|
-
className={
|
|
755
|
+
className={cx(clearButtonStyle, clearButtonFocusOverrideStyles)}
|
|
667
756
|
>
|
|
668
757
|
<Icon glyph="XWithCircle" />
|
|
669
758
|
</IconButton>
|
|
@@ -686,10 +775,13 @@ export default function Combobox<M extends boolean>({
|
|
|
686
775
|
isOpen,
|
|
687
776
|
]);
|
|
688
777
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Flag to determine whether the rendered options have icons
|
|
780
|
+
*/
|
|
781
|
+
const withIcons = useMemo(
|
|
782
|
+
() => allOptions.some(opt => opt.hasGlyph),
|
|
783
|
+
[allOptions],
|
|
784
|
+
);
|
|
693
785
|
|
|
694
786
|
/**
|
|
695
787
|
*
|
|
@@ -698,6 +790,7 @@ export default function Combobox<M extends boolean>({
|
|
|
698
790
|
*/
|
|
699
791
|
|
|
700
792
|
const onCloseMenu = useCallback(() => {
|
|
793
|
+
// Single select, and no change to selection
|
|
701
794
|
if (!isMultiselect(selection) && selection === prevSelection) {
|
|
702
795
|
const exactMatchedOption = visibleOptions.find(
|
|
703
796
|
option =>
|
|
@@ -711,12 +804,15 @@ export default function Combobox<M extends boolean>({
|
|
|
711
804
|
} else {
|
|
712
805
|
// Revert the value to the previous selection
|
|
713
806
|
const displayName =
|
|
714
|
-
getDisplayNameForValue(
|
|
807
|
+
getDisplayNameForValue(
|
|
808
|
+
selection as SelectValueType<false>,
|
|
809
|
+
allOptions,
|
|
810
|
+
) ?? '';
|
|
715
811
|
setInputValue(displayName);
|
|
716
812
|
}
|
|
717
813
|
}
|
|
718
814
|
}, [
|
|
719
|
-
|
|
815
|
+
allOptions,
|
|
720
816
|
inputValue,
|
|
721
817
|
isMultiselect,
|
|
722
818
|
prevSelection,
|
|
@@ -729,20 +825,23 @@ export default function Combobox<M extends boolean>({
|
|
|
729
825
|
if (doesSelectionExist) {
|
|
730
826
|
if (isMultiselect(selection)) {
|
|
731
827
|
// Scroll the wrapper to the end. No effect if not `overflow="scroll-x"`
|
|
732
|
-
|
|
828
|
+
scrollInputToEnd();
|
|
733
829
|
} else if (!isMultiselect(selection)) {
|
|
734
830
|
// Update the text input
|
|
735
831
|
const displayName =
|
|
736
|
-
getDisplayNameForValue(
|
|
832
|
+
getDisplayNameForValue(
|
|
833
|
+
selection as SelectValueType<false>,
|
|
834
|
+
allOptions,
|
|
835
|
+
) ?? '';
|
|
737
836
|
setInputValue(displayName);
|
|
738
837
|
closeMenu();
|
|
739
838
|
}
|
|
740
839
|
} else {
|
|
741
840
|
setInputValue('');
|
|
742
841
|
}
|
|
743
|
-
}, [doesSelectionExist,
|
|
842
|
+
}, [doesSelectionExist, allOptions, isMultiselect, selection]);
|
|
744
843
|
|
|
745
|
-
// Set initialValue
|
|
844
|
+
// Set the initialValue
|
|
746
845
|
useEffect(() => {
|
|
747
846
|
if (initialValue) {
|
|
748
847
|
if (isArray(initialValue)) {
|
|
@@ -788,27 +887,34 @@ export default function Combobox<M extends boolean>({
|
|
|
788
887
|
|
|
789
888
|
// when the menu closes, update the value if needed
|
|
790
889
|
useEffect(() => {
|
|
791
|
-
if (!isOpen &&
|
|
890
|
+
if (!isOpen && wasOpen) {
|
|
792
891
|
onCloseMenu();
|
|
793
892
|
}
|
|
794
|
-
}, [isOpen,
|
|
893
|
+
}, [isOpen, wasOpen, onCloseMenu]);
|
|
795
894
|
|
|
796
895
|
/**
|
|
797
896
|
*
|
|
798
897
|
* Menu management
|
|
799
898
|
*
|
|
800
899
|
*/
|
|
801
|
-
const closeMenu = () => setOpen(false);
|
|
802
|
-
const openMenu = () => setOpen(true);
|
|
803
900
|
|
|
804
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
|
|
805
905
|
useEffect(() => {
|
|
806
906
|
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
807
|
-
}, [comboboxRef, isOpen,
|
|
907
|
+
}, [comboboxRef, isOpen, highlightedOption, selection]);
|
|
908
|
+
|
|
909
|
+
// Handler fired when the manu has finished transitioning in/out
|
|
808
910
|
const handleTransitionEnd = () => {
|
|
809
911
|
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
810
912
|
};
|
|
811
913
|
|
|
914
|
+
/**
|
|
915
|
+
* The rendered menu JSX contents
|
|
916
|
+
* Includes error, empty, search and default states
|
|
917
|
+
*/
|
|
812
918
|
const renderedMenuContents = useMemo((): JSX.Element => {
|
|
813
919
|
switch (searchState) {
|
|
814
920
|
case 'loading': {
|
|
@@ -835,53 +941,23 @@ export default function Combobox<M extends boolean>({
|
|
|
835
941
|
|
|
836
942
|
case 'unset':
|
|
837
943
|
default: {
|
|
838
|
-
if (
|
|
839
|
-
return <ul className={menuList}>{
|
|
944
|
+
if (renderedOptionsJSX && renderedOptionsJSX.length > 0) {
|
|
945
|
+
return <ul className={menuList}>{renderedOptionsJSX}</ul>;
|
|
840
946
|
}
|
|
841
947
|
|
|
842
948
|
return <span className={menuMessage}>{searchEmptyMessage}</span>;
|
|
843
949
|
}
|
|
844
950
|
}
|
|
845
951
|
}, [
|
|
846
|
-
|
|
952
|
+
renderedOptionsJSX,
|
|
847
953
|
searchEmptyMessage,
|
|
848
954
|
searchErrorMessage,
|
|
849
955
|
searchLoadingMessage,
|
|
850
956
|
searchState,
|
|
851
957
|
]);
|
|
852
958
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// Set the max height of the menu
|
|
856
|
-
const maxHeight = useMemo(() => {
|
|
857
|
-
// TODO - consolidate this hook with Select/ListMenu
|
|
858
|
-
const maxMenuHeight = 274;
|
|
859
|
-
const menuMargin = 8;
|
|
860
|
-
|
|
861
|
-
if (viewportSize && comboboxRef.current && menuRef.current) {
|
|
862
|
-
const {
|
|
863
|
-
top: triggerTop,
|
|
864
|
-
bottom: triggerBottom,
|
|
865
|
-
} = comboboxRef.current.getBoundingClientRect();
|
|
866
|
-
|
|
867
|
-
// Find out how much space is available above or below the trigger
|
|
868
|
-
const safeSpace = Math.max(
|
|
869
|
-
viewportSize.height - triggerBottom,
|
|
870
|
-
triggerTop,
|
|
871
|
-
);
|
|
872
|
-
|
|
873
|
-
// if there's more than enough space, set to maxMenuHeight
|
|
874
|
-
// otherwise fill the space available
|
|
875
|
-
return Math.min(maxMenuHeight, safeSpace - menuMargin);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
return maxMenuHeight;
|
|
879
|
-
}, [viewportSize, comboboxRef, menuRef]);
|
|
880
|
-
|
|
881
|
-
// Scroll the menu when the focus changes
|
|
882
|
-
useEffect(() => {
|
|
883
|
-
// get the focused option
|
|
884
|
-
}, [focusedOption]);
|
|
959
|
+
/** The max height of the menu element */
|
|
960
|
+
const maxHeight = Math.min(256, useAvailableSpace(comboboxRef));
|
|
885
961
|
|
|
886
962
|
/**
|
|
887
963
|
*
|
|
@@ -897,7 +973,9 @@ export default function Combobox<M extends boolean>({
|
|
|
897
973
|
};
|
|
898
974
|
|
|
899
975
|
// Set focus to the input element on click
|
|
900
|
-
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)
|
|
901
979
|
if (e.target !== inputRef.current) {
|
|
902
980
|
let cursorPos = 0;
|
|
903
981
|
|
|
@@ -912,10 +990,12 @@ export default function Combobox<M extends boolean>({
|
|
|
912
990
|
}
|
|
913
991
|
};
|
|
914
992
|
|
|
915
|
-
// Fired
|
|
916
|
-
|
|
917
|
-
|
|
993
|
+
// Fired whenever the wrapper gains focus,
|
|
994
|
+
// and any time the focus within changes
|
|
995
|
+
const handleComboboxFocus = (e: React.FocusEvent) => {
|
|
996
|
+
scrollInputToEnd();
|
|
918
997
|
openMenu();
|
|
998
|
+
trackFocusedElement(getNameFromElement(e.target));
|
|
919
999
|
};
|
|
920
1000
|
|
|
921
1001
|
// Fired onChange
|
|
@@ -928,7 +1008,7 @@ export default function Combobox<M extends boolean>({
|
|
|
928
1008
|
};
|
|
929
1009
|
|
|
930
1010
|
const handleClearButtonFocus = () => {
|
|
931
|
-
|
|
1011
|
+
sethighlightedOption(null);
|
|
932
1012
|
};
|
|
933
1013
|
|
|
934
1014
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
@@ -939,6 +1019,7 @@ export default function Combobox<M extends boolean>({
|
|
|
939
1019
|
|
|
940
1020
|
const isFocusInComponent = isFocusOnCombobox || isFocusInMenu;
|
|
941
1021
|
|
|
1022
|
+
// Only run if the focus is in the component
|
|
942
1023
|
if (isFocusInComponent) {
|
|
943
1024
|
// No support for modifiers yet
|
|
944
1025
|
// TODO - Handle support for multiple chip selection
|
|
@@ -946,15 +1027,13 @@ export default function Combobox<M extends boolean>({
|
|
|
946
1027
|
return;
|
|
947
1028
|
}
|
|
948
1029
|
|
|
949
|
-
const focusedElement = getFocusedElementName();
|
|
950
|
-
|
|
951
1030
|
switch (event.keyCode) {
|
|
952
1031
|
case keyMap.Tab: {
|
|
953
|
-
switch (
|
|
1032
|
+
switch (focusedElementName) {
|
|
954
1033
|
case 'Input': {
|
|
955
1034
|
if (!doesSelectionExist) {
|
|
956
1035
|
closeMenu();
|
|
957
|
-
|
|
1036
|
+
updateHighlightedOption('first');
|
|
958
1037
|
updateFocusedChip(null);
|
|
959
1038
|
}
|
|
960
1039
|
// else use default behavior
|
|
@@ -983,21 +1062,25 @@ export default function Combobox<M extends boolean>({
|
|
|
983
1062
|
|
|
984
1063
|
case keyMap.Escape: {
|
|
985
1064
|
closeMenu();
|
|
986
|
-
|
|
1065
|
+
updateHighlightedOption('first');
|
|
987
1066
|
break;
|
|
988
1067
|
}
|
|
989
1068
|
|
|
990
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
|
|
991
1074
|
if (
|
|
992
|
-
// Focused on input element
|
|
993
|
-
document.activeElement === inputRef.current &&
|
|
994
1075
|
isOpen &&
|
|
995
|
-
|
|
1076
|
+
focusedElementName === ComboboxElement.Input &&
|
|
1077
|
+
!isNull(highlightedOption) &&
|
|
1078
|
+
!isOptionDisabled(highlightedOption)
|
|
996
1079
|
) {
|
|
997
|
-
updateSelection(
|
|
1080
|
+
updateSelection(highlightedOption);
|
|
998
1081
|
} else if (
|
|
999
1082
|
// Focused on clear button
|
|
1000
|
-
|
|
1083
|
+
focusedElementName === ComboboxElement.ClearButton
|
|
1001
1084
|
) {
|
|
1002
1085
|
updateSelection(null);
|
|
1003
1086
|
setInputFocus();
|
|
@@ -1006,16 +1089,18 @@ export default function Combobox<M extends boolean>({
|
|
|
1006
1089
|
}
|
|
1007
1090
|
|
|
1008
1091
|
case keyMap.Backspace: {
|
|
1009
|
-
// Backspace key focuses last chip
|
|
1010
|
-
//
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
+
}
|
|
1018
1101
|
}
|
|
1102
|
+
// Open the menu regardless
|
|
1103
|
+
openMenu();
|
|
1019
1104
|
break;
|
|
1020
1105
|
}
|
|
1021
1106
|
|
|
@@ -1023,9 +1108,11 @@ export default function Combobox<M extends boolean>({
|
|
|
1023
1108
|
if (isOpen) {
|
|
1024
1109
|
// Prevent the page from scrolling
|
|
1025
1110
|
event.preventDefault();
|
|
1111
|
+
// only change option if the menu is already open
|
|
1112
|
+
updateHighlightedOption('next');
|
|
1113
|
+
} else {
|
|
1114
|
+
openMenu();
|
|
1026
1115
|
}
|
|
1027
|
-
openMenu();
|
|
1028
|
-
updateFocusedOption('next');
|
|
1029
1116
|
break;
|
|
1030
1117
|
}
|
|
1031
1118
|
|
|
@@ -1033,8 +1120,11 @@ export default function Combobox<M extends boolean>({
|
|
|
1033
1120
|
if (isOpen) {
|
|
1034
1121
|
// Prevent the page from scrolling
|
|
1035
1122
|
event.preventDefault();
|
|
1123
|
+
// only change option if the menu is already open
|
|
1124
|
+
updateHighlightedOption('prev');
|
|
1125
|
+
} else {
|
|
1126
|
+
openMenu();
|
|
1036
1127
|
}
|
|
1037
|
-
updateFocusedOption('prev');
|
|
1038
1128
|
break;
|
|
1039
1129
|
}
|
|
1040
1130
|
|
|
@@ -1062,19 +1152,44 @@ export default function Combobox<M extends boolean>({
|
|
|
1062
1152
|
* Global Event Handler
|
|
1063
1153
|
*
|
|
1064
1154
|
*/
|
|
1065
|
-
// Global backdrop click handler
|
|
1066
|
-
const handleBackdropClick = ({ target }: MouseEvent) => {
|
|
1067
|
-
const isChildFocused =
|
|
1068
|
-
menuRef.current?.contains(target as Node) ||
|
|
1069
|
-
comboboxRef.current?.contains(target as Node) ||
|
|
1070
|
-
false;
|
|
1071
1155
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
+
);
|
|
1078
1193
|
|
|
1079
1194
|
const popoverProps = {
|
|
1080
1195
|
popoverZIndex,
|
|
@@ -1110,64 +1225,69 @@ export default function Combobox<M extends boolean>({
|
|
|
1110
1225
|
>
|
|
1111
1226
|
<div>
|
|
1112
1227
|
{label && (
|
|
1113
|
-
<Label
|
|
1228
|
+
<Label
|
|
1229
|
+
id={labelId}
|
|
1230
|
+
htmlFor={inputId}
|
|
1231
|
+
className={_tempLabelDescriptionOverrideStyle}
|
|
1232
|
+
>
|
|
1114
1233
|
{label}
|
|
1115
1234
|
</Label>
|
|
1116
1235
|
)}
|
|
1117
|
-
{description &&
|
|
1236
|
+
{description && (
|
|
1237
|
+
<Description className={_tempLabelDescriptionOverrideStyle}>
|
|
1238
|
+
{description}
|
|
1239
|
+
</Description>
|
|
1240
|
+
)}
|
|
1118
1241
|
</div>
|
|
1119
1242
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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}
|
|
1124
1262
|
>
|
|
1125
|
-
{/* Disable eslint: onClick sets focus. Key events would already have focus */}
|
|
1126
|
-
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
|
1127
1263
|
<div
|
|
1128
|
-
ref={
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
onClick={handleInputWrapperClick}
|
|
1137
|
-
onFocus={handleInputWrapperFocus}
|
|
1138
|
-
onKeyDown={handleKeyDown}
|
|
1139
|
-
onTransitionEnd={handleTransitionEnd}
|
|
1140
|
-
data-disabled={disabled}
|
|
1141
|
-
data-state={state}
|
|
1264
|
+
ref={inputWrapperRef}
|
|
1265
|
+
className={inputWrapperStyle({
|
|
1266
|
+
overflow,
|
|
1267
|
+
isOpen,
|
|
1268
|
+
selection,
|
|
1269
|
+
size,
|
|
1270
|
+
value: inputValue,
|
|
1271
|
+
})}
|
|
1142
1272
|
>
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
{
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
ref={inputRef}
|
|
1159
|
-
id={inputId}
|
|
1160
|
-
className={inputElementStyle}
|
|
1161
|
-
placeholder={placeholderValue}
|
|
1162
|
-
disabled={disabled ?? undefined}
|
|
1163
|
-
onChange={handleInputChange}
|
|
1164
|
-
value={inputValue}
|
|
1165
|
-
autoComplete="off"
|
|
1166
|
-
/>
|
|
1167
|
-
</div>
|
|
1168
|
-
{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
|
+
/>
|
|
1169
1288
|
</div>
|
|
1170
|
-
|
|
1289
|
+
{renderedInputIcons}
|
|
1290
|
+
</div>
|
|
1171
1291
|
|
|
1172
1292
|
{state === 'error' && errorMessage && (
|
|
1173
1293
|
<div className={errorMessageStyle}>{errorMessage}</div>
|
|
@@ -1201,4 +1321,61 @@ export default function Combobox<M extends boolean>({
|
|
|
1201
1321
|
</div>
|
|
1202
1322
|
</ComboboxContext.Provider>
|
|
1203
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
|
+
}
|
|
1204
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
|
+
*/
|