@khanacademy/wonder-blocks-dropdown 2.7.4 → 2.8.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 +28 -0
- package/dist/es/index.js +167 -167
- package/dist/index.js +389 -360
- package/package.json +7 -7
- package/src/components/__docs__/action-menu.argtypes.js +44 -0
- package/src/components/__docs__/action-menu.stories.js +435 -0
- package/src/components/__docs__/base-select.argtypes.js +54 -0
- package/src/components/__docs__/multi-select.stories.js +509 -0
- package/src/components/__docs__/single-select.accessibility.stories.mdx +59 -0
- package/src/components/__docs__/single-select.argtypes.js +54 -0
- package/src/components/__docs__/single-select.stories.js +464 -0
- package/src/components/__tests__/dropdown-core-virtualized.test.js +0 -15
- package/src/components/__tests__/dropdown-core.test.js +113 -209
- package/src/components/__tests__/multi-select.test.js +49 -3
- package/src/components/__tests__/single-select.test.js +43 -50
- package/src/components/action-menu.js +11 -0
- package/src/components/dropdown-core-virtualized.js +0 -5
- package/src/components/dropdown-core.js +224 -130
- package/src/components/multi-select.js +18 -33
- package/src/components/single-select.js +16 -30
- package/src/util/__tests__/dropdown-menu-styles.test.js +0 -26
- package/src/util/__tests__/helpers.test.js +73 -0
- package/src/util/constants.js +0 -11
- package/src/util/dropdown-menu-styles.js +0 -5
- package/src/util/helpers.js +44 -0
- package/src/util/types.js +2 -5
- package/src/components/__tests__/search-text-input.test.js +0 -212
- package/src/components/action-menu.stories.js +0 -48
- package/src/components/multi-select.stories.js +0 -124
- package/src/components/search-text-input.js +0 -115
- package/src/components/single-select.stories.js +0 -247
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
// @flow
|
|
2
3
|
// A menu that consists of action items
|
|
3
4
|
|
|
@@ -11,6 +12,7 @@ import Color, {fade} from "@khanacademy/wonder-blocks-color";
|
|
|
11
12
|
|
|
12
13
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
13
14
|
import {addStyle, View} from "@khanacademy/wonder-blocks-core";
|
|
15
|
+
import SearchField from "@khanacademy/wonder-blocks-search-field";
|
|
14
16
|
import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
|
|
15
17
|
import {withActionScheduler} from "@khanacademy/wonder-blocks-timing";
|
|
16
18
|
|
|
@@ -21,10 +23,10 @@ import type {
|
|
|
21
23
|
} from "@khanacademy/wonder-blocks-timing";
|
|
22
24
|
import DropdownCoreVirtualized from "./dropdown-core-virtualized.js";
|
|
23
25
|
import SeparatorItem from "./separator-item.js";
|
|
24
|
-
import
|
|
25
|
-
import {defaultLabels, keyCodes, searchInputStyle} from "../util/constants.js";
|
|
26
|
+
import {defaultLabels, keyCodes} from "../util/constants.js";
|
|
26
27
|
import type {DropdownItem} from "../util/types.js";
|
|
27
28
|
import DropdownPopper from "./dropdown-popper.js";
|
|
29
|
+
import {debounce, getStringForKey} from "../util/helpers.js";
|
|
28
30
|
import {
|
|
29
31
|
generateDropdownMenuStyles,
|
|
30
32
|
getDropdownMenuHeight,
|
|
@@ -45,6 +47,19 @@ const VIRTUALIZE_THRESHOLD = 125;
|
|
|
45
47
|
const StyledSpan = addStyle("span");
|
|
46
48
|
|
|
47
49
|
type Labels = {|
|
|
50
|
+
/**
|
|
51
|
+
* Label for describing the dismiss icon on the search filter.
|
|
52
|
+
*/
|
|
53
|
+
clearSearch: string,
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Label for the search placeholder.
|
|
57
|
+
*/
|
|
58
|
+
filter: string,
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Label for when the filter returns no results.
|
|
62
|
+
*/
|
|
48
63
|
noResults: string,
|
|
49
64
|
/**
|
|
50
65
|
* The number total of items available.
|
|
@@ -81,6 +96,11 @@ type DefaultProps = {|
|
|
|
81
96
|
* use when the item is used on a dark background.
|
|
82
97
|
*/
|
|
83
98
|
light: boolean,
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Used to determine if we can automatically select an item using the keyboard.
|
|
102
|
+
*/
|
|
103
|
+
selectionType: "single" | "multi",
|
|
84
104
|
|};
|
|
85
105
|
|
|
86
106
|
type DropdownAriaRole = "listbox" | "menu";
|
|
@@ -95,15 +115,15 @@ type Props = {|
|
|
|
95
115
|
|
|
96
116
|
/**
|
|
97
117
|
* An optional handler to set the searchText of the parent. When this and
|
|
98
|
-
* the searchText exist,
|
|
99
|
-
*
|
|
118
|
+
* the searchText exist, SearchField will be displayed at the top of the
|
|
119
|
+
* dropdown body.
|
|
100
120
|
*/
|
|
101
121
|
onSearchTextChanged?: ?(searchText: string) => mixed,
|
|
102
122
|
|
|
103
123
|
/**
|
|
104
124
|
* An optional string that the user entered to search the items. When this
|
|
105
|
-
* and the onSearchTextChanged exist,
|
|
106
|
-
*
|
|
125
|
+
* and the onSearchTextChanged exist, SearchField will be displayed at the
|
|
126
|
+
* top of the dropdown body.
|
|
107
127
|
*/
|
|
108
128
|
searchText?: ?string,
|
|
109
129
|
|
|
@@ -149,6 +169,12 @@ type Props = {|
|
|
|
149
169
|
*/
|
|
150
170
|
role: DropdownAriaRole,
|
|
151
171
|
|
|
172
|
+
/**
|
|
173
|
+
* When this is true, the dropdown body shows a search text input at the
|
|
174
|
+
* top. The items will be filtered by the input.
|
|
175
|
+
*/
|
|
176
|
+
isFilterable?: boolean,
|
|
177
|
+
|
|
152
178
|
...WithActionSchedulerProps,
|
|
153
179
|
|};
|
|
154
180
|
|
|
@@ -159,6 +185,11 @@ type State = {|
|
|
|
159
185
|
*/
|
|
160
186
|
itemRefs: Array<{|ref: {|current: any|}, originalIndex: number|}>,
|
|
161
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The object containing the custom labels used inside this component.
|
|
190
|
+
*/
|
|
191
|
+
labels: Labels,
|
|
192
|
+
|
|
162
193
|
/**
|
|
163
194
|
* Because getDerivedStateFromProps doesn't store previous props (in the
|
|
164
195
|
* spirit of performance), we store the previous items just to be able to
|
|
@@ -172,11 +203,6 @@ type State = {|
|
|
|
172
203
|
* resetting focusedIndex and focusedOriginalIndex when an update happens.
|
|
173
204
|
*/
|
|
174
205
|
sameItemsFocusable: boolean,
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* The object containing the custom labels used inside this component.
|
|
178
|
-
*/
|
|
179
|
-
labels: Labels,
|
|
180
206
|
|};
|
|
181
207
|
|
|
182
208
|
/**
|
|
@@ -200,6 +226,10 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
200
226
|
current: null | React.ElementRef<typeof List>,
|
|
201
227
|
|};
|
|
202
228
|
|
|
229
|
+
handleKeyDownDebounced: (key: string) => void;
|
|
230
|
+
|
|
231
|
+
textSuggestion: string;
|
|
232
|
+
|
|
203
233
|
// Figure out if the same items are focusable. If an item has been added or
|
|
204
234
|
// removed, this method will return false.
|
|
205
235
|
static sameItemsFocusable(
|
|
@@ -220,10 +250,13 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
220
250
|
static defaultProps: DefaultProps = {
|
|
221
251
|
alignment: "left",
|
|
222
252
|
labels: {
|
|
253
|
+
clearSearch: defaultLabels.clearSearch,
|
|
254
|
+
filter: defaultLabels.filter,
|
|
223
255
|
noResults: defaultLabels.noResults,
|
|
224
256
|
someSelected: defaultLabels.someSelected,
|
|
225
257
|
},
|
|
226
258
|
light: false,
|
|
259
|
+
selectionType: "single",
|
|
227
260
|
};
|
|
228
261
|
|
|
229
262
|
// This is here to avoid calling React.createRef on each rerender. Instead,
|
|
@@ -274,6 +307,15 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
274
307
|
};
|
|
275
308
|
|
|
276
309
|
this.virtualizedListRef = React.createRef();
|
|
310
|
+
|
|
311
|
+
// We debounce the keydown handler to get the ASCII chars because it's
|
|
312
|
+
// called on every keydown
|
|
313
|
+
this.handleKeyDownDebounced = debounce(
|
|
314
|
+
this.handleKeyDownDebounceResult,
|
|
315
|
+
// Leaving enough time for the user to type a valid query (e.g. jul)
|
|
316
|
+
500,
|
|
317
|
+
);
|
|
318
|
+
this.textSuggestion = "";
|
|
277
319
|
}
|
|
278
320
|
|
|
279
321
|
componentDidMount() {
|
|
@@ -333,31 +375,21 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
333
375
|
this.removeEventListeners();
|
|
334
376
|
}
|
|
335
377
|
|
|
336
|
-
|
|
337
|
-
return (
|
|
338
|
-
!!this.props.onSearchTextChanged &&
|
|
339
|
-
typeof this.props.searchText === "string"
|
|
340
|
-
);
|
|
341
|
-
}
|
|
378
|
+
searchFieldRef: {|current: null | HTMLInputElement|} = React.createRef();
|
|
342
379
|
|
|
343
|
-
// Resets our initial focus index to what was passed in
|
|
344
|
-
|
|
345
|
-
resetFocusedIndex() {
|
|
380
|
+
// Resets our initial focus index to what was passed in via the props
|
|
381
|
+
resetFocusedIndex(): void {
|
|
346
382
|
const {initialFocusedIndex} = this.props;
|
|
347
383
|
|
|
348
|
-
// If we are given an initial focus index, select it.
|
|
349
|
-
//
|
|
350
|
-
if (initialFocusedIndex) {
|
|
351
|
-
|
|
352
|
-
// index is going to be offset by 1, since the orginal
|
|
353
|
-
// index doesn't account for the search box's
|
|
354
|
-
// existence.
|
|
355
|
-
if (this.hasSearchBox()) {
|
|
356
|
-
this.focusedIndex = initialFocusedIndex + 1;
|
|
357
|
-
} else {
|
|
358
|
-
this.focusedIndex = initialFocusedIndex;
|
|
359
|
-
}
|
|
384
|
+
// If we are given an initial focus index, select it. Otherwise default
|
|
385
|
+
// to the first item
|
|
386
|
+
if (typeof initialFocusedIndex !== "undefined") {
|
|
387
|
+
this.focusedIndex = initialFocusedIndex;
|
|
360
388
|
} else {
|
|
389
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
390
|
+
return this.focusSearchField();
|
|
391
|
+
}
|
|
392
|
+
|
|
361
393
|
this.focusedIndex = 0;
|
|
362
394
|
}
|
|
363
395
|
}
|
|
@@ -408,20 +440,26 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
408
440
|
}
|
|
409
441
|
};
|
|
410
442
|
|
|
411
|
-
scheduleToFocusCurrentItem() {
|
|
443
|
+
scheduleToFocusCurrentItem(onFocus?: (node: void | HTMLElement) => void) {
|
|
412
444
|
if (this.shouldVirtualizeList()) {
|
|
413
445
|
// wait for windowed items to be recalculated
|
|
414
|
-
this.props.schedule.animationFrame(() =>
|
|
446
|
+
this.props.schedule.animationFrame(() => {
|
|
447
|
+
this.focusCurrentItem(onFocus);
|
|
448
|
+
});
|
|
415
449
|
} else {
|
|
416
450
|
// immediately focus the current item if we're not virtualizing
|
|
417
|
-
this.focusCurrentItem();
|
|
451
|
+
this.focusCurrentItem(onFocus);
|
|
418
452
|
}
|
|
419
453
|
}
|
|
420
454
|
|
|
421
|
-
|
|
422
|
-
|
|
455
|
+
/**
|
|
456
|
+
* Focus on the current item.
|
|
457
|
+
* @param [onFocus] - Callback to be called when the item is focused.
|
|
458
|
+
*/
|
|
459
|
+
focusCurrentItem(onFocus?: (node: HTMLElement) => void) {
|
|
460
|
+
const focusedItemRef = this.state.itemRefs[this.focusedIndex];
|
|
423
461
|
|
|
424
|
-
if (
|
|
462
|
+
if (focusedItemRef) {
|
|
425
463
|
// force react-window to scroll to ensure the focused item is visible
|
|
426
464
|
if (this.virtualizedListRef.current) {
|
|
427
465
|
// Our focused index does not include disabled items, but the
|
|
@@ -429,24 +467,50 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
429
467
|
// in the count. So we need to use "originalIndex", which
|
|
430
468
|
// does account for disabled items.
|
|
431
469
|
this.virtualizedListRef.current.scrollToItem(
|
|
432
|
-
|
|
470
|
+
focusedItemRef.originalIndex,
|
|
433
471
|
);
|
|
434
472
|
}
|
|
435
473
|
|
|
436
474
|
const node = ((ReactDOM.findDOMNode(
|
|
437
|
-
|
|
475
|
+
focusedItemRef.ref.current,
|
|
438
476
|
): any): HTMLElement);
|
|
439
477
|
if (node) {
|
|
440
478
|
node.focus();
|
|
441
479
|
// Keep track of the original index of the newly focused item.
|
|
442
480
|
// To be used if the set of focusable items in the menu changes
|
|
443
|
-
this.focusedOriginalIndex =
|
|
481
|
+
this.focusedOriginalIndex = focusedItemRef.originalIndex;
|
|
482
|
+
|
|
483
|
+
if (onFocus) {
|
|
484
|
+
// Call the callback with the node that was focused.
|
|
485
|
+
onFocus(node);
|
|
486
|
+
}
|
|
444
487
|
}
|
|
445
488
|
}
|
|
446
489
|
}
|
|
447
490
|
|
|
448
|
-
|
|
491
|
+
focusSearchField() {
|
|
492
|
+
if (this.searchFieldRef.current) {
|
|
493
|
+
this.searchFieldRef.current.focus();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
hasSearchField(): boolean {
|
|
498
|
+
return !!this.props.isFilterable;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
isSearchFieldFocused(): boolean {
|
|
502
|
+
return (
|
|
503
|
+
this.hasSearchField() &&
|
|
504
|
+
document.activeElement === this.searchFieldRef.current
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
focusPreviousItem(): void {
|
|
449
509
|
if (this.focusedIndex === 0) {
|
|
510
|
+
// Move the focus to the search field if it is the first item.
|
|
511
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
512
|
+
return this.focusSearchField();
|
|
513
|
+
}
|
|
450
514
|
this.focusedIndex = this.state.itemRefs.length - 1;
|
|
451
515
|
} else {
|
|
452
516
|
this.focusedIndex -= 1;
|
|
@@ -455,8 +519,12 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
455
519
|
this.scheduleToFocusCurrentItem();
|
|
456
520
|
}
|
|
457
521
|
|
|
458
|
-
focusNextItem() {
|
|
522
|
+
focusNextItem(): void {
|
|
459
523
|
if (this.focusedIndex === this.state.itemRefs.length - 1) {
|
|
524
|
+
// Move the focus to the search field if it is the last item.
|
|
525
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
526
|
+
return this.focusSearchField();
|
|
527
|
+
}
|
|
460
528
|
this.focusedIndex = 0;
|
|
461
529
|
} else {
|
|
462
530
|
this.focusedIndex += 1;
|
|
@@ -478,6 +546,15 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
478
546
|
handleKeyDown: (event: SyntheticKeyboardEvent<>) => void = (event) => {
|
|
479
547
|
const {onOpenChanged, open, searchText} = this.props;
|
|
480
548
|
const keyCode = event.which || event.keyCode;
|
|
549
|
+
|
|
550
|
+
// Listen for the keydown events if we are using ASCII characters.
|
|
551
|
+
if (getStringForKey(event.key)) {
|
|
552
|
+
event.stopPropagation();
|
|
553
|
+
this.textSuggestion += event.key;
|
|
554
|
+
// Trigger the filter logic only after the debounce is resolved.
|
|
555
|
+
this.handleKeyDownDebounced(this.textSuggestion);
|
|
556
|
+
}
|
|
557
|
+
|
|
481
558
|
// If menu isn't open and user presses down, open the menu
|
|
482
559
|
if (!open) {
|
|
483
560
|
if (keyCode === keyCodes.down) {
|
|
@@ -491,24 +568,20 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
491
568
|
// Handle all other key behavior
|
|
492
569
|
switch (keyCode) {
|
|
493
570
|
case keyCodes.tab:
|
|
494
|
-
// When we show
|
|
571
|
+
// When we show SearchField and that is focused and the
|
|
495
572
|
// searchText is entered at least one character, dismiss button
|
|
496
|
-
// is displayed. When user presses tab, we should move focus
|
|
497
|
-
//
|
|
498
|
-
if (
|
|
499
|
-
this.hasSearchBox() &&
|
|
500
|
-
this.focusedIndex === 0 &&
|
|
501
|
-
searchText
|
|
502
|
-
) {
|
|
573
|
+
// is displayed. When user presses tab, we should move focus to
|
|
574
|
+
// the dismiss button.
|
|
575
|
+
if (this.isSearchFieldFocused() && searchText) {
|
|
503
576
|
return;
|
|
504
577
|
}
|
|
505
578
|
this.restoreTabOrder();
|
|
506
579
|
onOpenChanged(false);
|
|
507
580
|
return;
|
|
508
581
|
case keyCodes.space:
|
|
509
|
-
// When we display
|
|
510
|
-
//
|
|
511
|
-
if (this.
|
|
582
|
+
// When we display SearchField and the focus is on it, we should
|
|
583
|
+
// let the user type space.
|
|
584
|
+
if (this.isSearchFieldFocused()) {
|
|
512
585
|
return;
|
|
513
586
|
}
|
|
514
587
|
// Prevent space from scrolling down the page
|
|
@@ -531,9 +604,9 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
531
604
|
const keyCode = event.which || event.keyCode;
|
|
532
605
|
switch (keyCode) {
|
|
533
606
|
case keyCodes.space:
|
|
534
|
-
// When we display
|
|
535
|
-
//
|
|
536
|
-
if (this.
|
|
607
|
+
// When we display SearchField and the focus is on it, we should
|
|
608
|
+
// let the user type space.
|
|
609
|
+
if (this.isSearchFieldFocused()) {
|
|
537
610
|
return;
|
|
538
611
|
}
|
|
539
612
|
// Prevent space from scrolling down the page
|
|
@@ -551,6 +624,45 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
551
624
|
}
|
|
552
625
|
};
|
|
553
626
|
|
|
627
|
+
handleKeyDownDebounceResult: (key: string) => void = (key) => {
|
|
628
|
+
const foundIndex = this.props.items
|
|
629
|
+
.filter((item) => item.focusable)
|
|
630
|
+
.findIndex(({component}) => {
|
|
631
|
+
if (SeparatorItem.isClassOf(component)) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Flow doesn't know that the component is an OptionItem
|
|
636
|
+
// $FlowIgnore[incompatible-use]
|
|
637
|
+
const label = component.props?.label.toLowerCase();
|
|
638
|
+
|
|
639
|
+
return label.startsWith(key.toLowerCase());
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
if (foundIndex >= 0) {
|
|
643
|
+
const isClosed = !this.props.open;
|
|
644
|
+
if (isClosed) {
|
|
645
|
+
// Open the menu to be able to focus on the item that matches
|
|
646
|
+
// the text suggested.
|
|
647
|
+
this.props.onOpenChanged(true);
|
|
648
|
+
}
|
|
649
|
+
// Update the focus reference.
|
|
650
|
+
this.focusedIndex = foundIndex;
|
|
651
|
+
|
|
652
|
+
this.scheduleToFocusCurrentItem((node) => {
|
|
653
|
+
// Force click only if the dropdown is closed and we are using
|
|
654
|
+
// the SingleSelect component.
|
|
655
|
+
if (this.props.selectionType === "single" && isClosed && node) {
|
|
656
|
+
node.click();
|
|
657
|
+
this.props.onOpenChanged(false);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Otherwise, reset current text
|
|
663
|
+
this.textSuggestion = "";
|
|
664
|
+
};
|
|
665
|
+
|
|
554
666
|
handleClickFocus: (index: number) => void = (index) => {
|
|
555
667
|
// Turn itemsClicked on so pressing up or down would focus the
|
|
556
668
|
// appropriate item in handleKeyDown
|
|
@@ -588,17 +700,11 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
588
700
|
maybeRenderNoResults(): React.Node {
|
|
589
701
|
const {
|
|
590
702
|
items,
|
|
591
|
-
onSearchTextChanged,
|
|
592
|
-
searchText,
|
|
593
703
|
labels: {noResults},
|
|
594
704
|
} = this.props;
|
|
595
|
-
const showSearchTextInput =
|
|
596
|
-
!!onSearchTextChanged && typeof searchText === "string";
|
|
597
|
-
|
|
598
|
-
const includeSearchCount = showSearchTextInput ? 1 : 0;
|
|
599
705
|
|
|
600
706
|
// Verify if there are items to be rendered or not
|
|
601
|
-
const numResults = items.length
|
|
707
|
+
const numResults = items.length;
|
|
602
708
|
|
|
603
709
|
if (numResults === 0) {
|
|
604
710
|
return (
|
|
@@ -664,30 +770,6 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
664
770
|
? this.state.itemRefs[focusIndex].ref
|
|
665
771
|
: null;
|
|
666
772
|
|
|
667
|
-
// Render the SearchField component.
|
|
668
|
-
if (SearchTextInput.isClassOf(component)) {
|
|
669
|
-
return React.cloneElement(component, {
|
|
670
|
-
...populatedProps,
|
|
671
|
-
|
|
672
|
-
key: "search-text-input",
|
|
673
|
-
// pass the current ref down to the input element
|
|
674
|
-
itemRef: currentRef,
|
|
675
|
-
// override to avoid losing focus when pressing a key
|
|
676
|
-
onClick: () => {
|
|
677
|
-
this.handleClickFocus(0);
|
|
678
|
-
this.focusCurrentItem();
|
|
679
|
-
},
|
|
680
|
-
// apply custom styles
|
|
681
|
-
style: searchInputStyle,
|
|
682
|
-
// TODO(WB-1310): Remove the autofocus prop after making
|
|
683
|
-
// the search field sticky.
|
|
684
|
-
// Currently autofocusing on the search field to work
|
|
685
|
-
// around it losing focus on mount when switching between
|
|
686
|
-
// virtualized and non-virtualized dropdown filter results.
|
|
687
|
-
autofocus: this.focusedIndex === 0,
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
773
|
// Render OptionItem and/or ActionItem elements.
|
|
692
774
|
return React.cloneElement(component, {
|
|
693
775
|
...populatedProps,
|
|
@@ -721,30 +803,6 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
721
803
|
|
|
722
804
|
const focusIndex = focusCounter - 1;
|
|
723
805
|
|
|
724
|
-
if (SearchTextInput.isClassOf(item.component)) {
|
|
725
|
-
return {
|
|
726
|
-
...item,
|
|
727
|
-
// override to avoid losing focus when pressing a key
|
|
728
|
-
onClick: () => {
|
|
729
|
-
this.handleClickFocus(0);
|
|
730
|
-
this.focusCurrentItem();
|
|
731
|
-
},
|
|
732
|
-
populatedProps: {
|
|
733
|
-
style: searchInputStyle,
|
|
734
|
-
// pass the current ref down to the input element
|
|
735
|
-
itemRef: this.state.itemRefs[focusIndex]
|
|
736
|
-
? this.state.itemRefs[focusIndex].ref
|
|
737
|
-
: null,
|
|
738
|
-
// TODO(WB-1310): Remove the autofocus prop after making
|
|
739
|
-
// the search field sticky.
|
|
740
|
-
// Currently autofocusing on the search field to work
|
|
741
|
-
// around it losing focus on mount when switching between
|
|
742
|
-
// virtualized and non-virtualized dropdown filter results.
|
|
743
|
-
autofocus: this.focusedIndex === 0,
|
|
744
|
-
},
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
|
|
748
806
|
return {
|
|
749
807
|
...item,
|
|
750
808
|
role: itemRole,
|
|
@@ -774,6 +832,32 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
774
832
|
);
|
|
775
833
|
}
|
|
776
834
|
|
|
835
|
+
handleSearchTextChanged: (searchText: string) => void = (
|
|
836
|
+
searchText: string,
|
|
837
|
+
) => {
|
|
838
|
+
const {onSearchTextChanged} = this.props;
|
|
839
|
+
|
|
840
|
+
if (onSearchTextChanged) {
|
|
841
|
+
onSearchTextChanged(searchText);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
renderSearchField(): React.Node {
|
|
846
|
+
const {searchText} = this.props;
|
|
847
|
+
const {labels} = this.state;
|
|
848
|
+
|
|
849
|
+
return (
|
|
850
|
+
<SearchField
|
|
851
|
+
clearAriaLabel={labels.clearSearch}
|
|
852
|
+
onChange={this.handleSearchTextChanged}
|
|
853
|
+
placeholder={labels.filter}
|
|
854
|
+
ref={this.searchFieldRef}
|
|
855
|
+
style={styles.searchInputStyle}
|
|
856
|
+
value={searchText || ""}
|
|
857
|
+
/>
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
777
861
|
renderDropdownMenu(
|
|
778
862
|
listRenderer: React.Node,
|
|
779
863
|
isReferenceHidden: ?boolean,
|
|
@@ -788,33 +872,34 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
788
872
|
? openerStyle.getPropertyValue("width")
|
|
789
873
|
: 0;
|
|
790
874
|
|
|
791
|
-
|
|
792
|
-
const initialHeight = 12;
|
|
793
|
-
|
|
794
|
-
const maxDropdownHeight = getDropdownMenuHeight(
|
|
795
|
-
this.props.items,
|
|
796
|
-
initialHeight,
|
|
797
|
-
);
|
|
875
|
+
const maxDropdownHeight = getDropdownMenuHeight(this.props.items);
|
|
798
876
|
|
|
799
877
|
return (
|
|
800
878
|
<View
|
|
801
879
|
// Stop propagation to prevent the mouseup listener on the
|
|
802
880
|
// document from closing the menu.
|
|
803
881
|
onMouseUp={this.handleDropdownMouseUp}
|
|
804
|
-
role={this.props.role}
|
|
805
882
|
style={[
|
|
806
883
|
styles.dropdown,
|
|
807
884
|
light && styles.light,
|
|
808
885
|
isReferenceHidden && styles.hidden,
|
|
809
|
-
generateDropdownMenuStyles(
|
|
810
|
-
minDropdownWidth,
|
|
811
|
-
maxDropdownHeight,
|
|
812
|
-
),
|
|
813
|
-
|
|
814
886
|
dropdownStyle,
|
|
815
887
|
]}
|
|
888
|
+
testId="dropdown-core-container"
|
|
816
889
|
>
|
|
817
|
-
{
|
|
890
|
+
{this.props.isFilterable && this.renderSearchField()}
|
|
891
|
+
<View
|
|
892
|
+
role={this.props.role}
|
|
893
|
+
style={[
|
|
894
|
+
styles.listboxOrMenu,
|
|
895
|
+
generateDropdownMenuStyles(
|
|
896
|
+
minDropdownWidth,
|
|
897
|
+
maxDropdownHeight,
|
|
898
|
+
),
|
|
899
|
+
]}
|
|
900
|
+
>
|
|
901
|
+
{listRenderer}
|
|
902
|
+
</View>
|
|
818
903
|
{this.maybeRenderNoResults()}
|
|
819
904
|
</View>
|
|
820
905
|
);
|
|
@@ -850,9 +935,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
850
935
|
renderLiveRegion(): React.Node {
|
|
851
936
|
const {items, open} = this.props;
|
|
852
937
|
const {labels} = this.state;
|
|
853
|
-
const totalItems =
|
|
854
|
-
? items.length - 1
|
|
855
|
-
: items.length;
|
|
938
|
+
const totalItems = items.length;
|
|
856
939
|
|
|
857
940
|
return (
|
|
858
941
|
<StyledSpan
|
|
@@ -897,7 +980,6 @@ const styles = StyleSheet.create({
|
|
|
897
980
|
paddingBottom: Spacing.xxxSmall_4,
|
|
898
981
|
border: `solid 1px ${Color.offBlack16}`,
|
|
899
982
|
boxShadow: `0px 8px 8px 0px ${fade(Color.offBlack, 0.1)}`,
|
|
900
|
-
overflowY: "auto",
|
|
901
983
|
},
|
|
902
984
|
|
|
903
985
|
light: {
|
|
@@ -905,6 +987,10 @@ const styles = StyleSheet.create({
|
|
|
905
987
|
border: "none",
|
|
906
988
|
},
|
|
907
989
|
|
|
990
|
+
listboxOrMenu: {
|
|
991
|
+
overflowY: "auto",
|
|
992
|
+
},
|
|
993
|
+
|
|
908
994
|
hidden: {
|
|
909
995
|
pointerEvents: "none",
|
|
910
996
|
visibility: "hidden",
|
|
@@ -916,6 +1002,14 @@ const styles = StyleSheet.create({
|
|
|
916
1002
|
marginTop: Spacing.xxSmall_6,
|
|
917
1003
|
},
|
|
918
1004
|
|
|
1005
|
+
searchInputStyle: {
|
|
1006
|
+
margin: Spacing.xSmall_8,
|
|
1007
|
+
marginTop: Spacing.xxxSmall_4,
|
|
1008
|
+
// Set `minHeight` to "auto" to stop the search field from having
|
|
1009
|
+
// a height of 0 and being cut off.
|
|
1010
|
+
minHeight: "auto",
|
|
1011
|
+
},
|
|
1012
|
+
|
|
919
1013
|
srOnly: {
|
|
920
1014
|
border: 0,
|
|
921
1015
|
clip: "rect(0,0,0,0)",
|