@khanacademy/wonder-blocks-dropdown 2.7.3 → 2.7.6
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 +24 -0
- package/dist/es/index.js +92 -162
- package/dist/index.js +285 -374
- package/package.json +6 -6
- 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 +114 -208
- package/src/components/__tests__/multi-select.test.js +1 -3
- package/src/components/__tests__/single-select.test.js +15 -47
- package/src/components/action-menu.js +11 -0
- package/src/components/dropdown-core-virtualized.js +0 -5
- package/src/components/dropdown-core.js +140 -126
- package/src/components/multi-select.js +17 -33
- package/src/components/single-select.js +15 -30
- package/src/util/__tests__/dropdown-menu-styles.test.js +0 -26
- package/src/util/constants.js +0 -11
- package/src/util/dropdown-menu-styles.js +0 -5
- 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
|
@@ -214,7 +214,7 @@ describe("SingleSelect", () => {
|
|
|
214
214
|
render(<ControlledComponent onToggle={onToggleMock} />);
|
|
215
215
|
|
|
216
216
|
// Act
|
|
217
|
-
userEvent.click(screen.
|
|
217
|
+
userEvent.click(screen.getByRole("button", {name: "Choose"}));
|
|
218
218
|
|
|
219
219
|
// Assert
|
|
220
220
|
expect(onToggleMock).toHaveBeenCalledWith(true);
|
|
@@ -225,7 +225,7 @@ describe("SingleSelect", () => {
|
|
|
225
225
|
const onToggleMock = jest.fn();
|
|
226
226
|
render(<ControlledComponent onToggle={onToggleMock} />);
|
|
227
227
|
// open the menu from the outside
|
|
228
|
-
userEvent.click(screen.
|
|
228
|
+
userEvent.click(screen.getByRole("button", {name: "Choose"}));
|
|
229
229
|
|
|
230
230
|
// Act
|
|
231
231
|
// click on first item
|
|
@@ -264,10 +264,11 @@ describe("SingleSelect", () => {
|
|
|
264
264
|
render(<ControlledComponent />);
|
|
265
265
|
|
|
266
266
|
// Act
|
|
267
|
+
const opener = screen.getByRole("button", {name: "Choose"});
|
|
267
268
|
// open the menu from the outside
|
|
268
|
-
userEvent.click(
|
|
269
|
+
userEvent.click(opener);
|
|
269
270
|
// click on the dropdown anchor to hide the menu
|
|
270
|
-
userEvent.click(
|
|
271
|
+
userEvent.click(opener);
|
|
271
272
|
|
|
272
273
|
// Assert
|
|
273
274
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
@@ -360,12 +361,7 @@ describe("SingleSelect", () => {
|
|
|
360
361
|
testId="openTest"
|
|
361
362
|
onChange={jest.fn()}
|
|
362
363
|
opener={({text}) => (
|
|
363
|
-
<button
|
|
364
|
-
onClick={jest.fn()}
|
|
365
|
-
data-test-id="custom-opener"
|
|
366
|
-
>
|
|
367
|
-
{text}
|
|
368
|
-
</button>
|
|
364
|
+
<button onClick={jest.fn()}>{text}</button>
|
|
369
365
|
)}
|
|
370
366
|
>
|
|
371
367
|
<OptionItem label="Toggle A" value="toggle_a" />
|
|
@@ -374,7 +370,7 @@ describe("SingleSelect", () => {
|
|
|
374
370
|
);
|
|
375
371
|
|
|
376
372
|
// Act
|
|
377
|
-
const opener = screen.
|
|
373
|
+
const opener = screen.getByRole("button");
|
|
378
374
|
// open dropdown
|
|
379
375
|
userEvent.click(opener);
|
|
380
376
|
|
|
@@ -405,12 +401,7 @@ describe("SingleSelect", () => {
|
|
|
405
401
|
selectedValue={this.state.selectedValue}
|
|
406
402
|
placeholder="Custom placeholder"
|
|
407
403
|
opener={({text}) => (
|
|
408
|
-
<button
|
|
409
|
-
onClick={jest.fn()}
|
|
410
|
-
data-test-id="custom-opener"
|
|
411
|
-
>
|
|
412
|
-
{text}
|
|
413
|
-
</button>
|
|
404
|
+
<button onClick={jest.fn()}>{text}</button>
|
|
414
405
|
)}
|
|
415
406
|
>
|
|
416
407
|
<OptionItem label="Toggle A" value="toggle_a" />
|
|
@@ -424,7 +415,7 @@ describe("SingleSelect", () => {
|
|
|
424
415
|
render(<ControlledComponent />);
|
|
425
416
|
|
|
426
417
|
// Act
|
|
427
|
-
const opener = screen.
|
|
418
|
+
const opener = screen.getByRole("button");
|
|
428
419
|
// open dropdown
|
|
429
420
|
userEvent.click(opener);
|
|
430
421
|
userEvent.click(screen.getByText("Toggle B"));
|
|
@@ -437,7 +428,7 @@ describe("SingleSelect", () => {
|
|
|
437
428
|
});
|
|
438
429
|
|
|
439
430
|
describe("isFilterable", () => {
|
|
440
|
-
it("displays
|
|
431
|
+
it("displays SearchField when isFilterable is true", () => {
|
|
441
432
|
// Arrange
|
|
442
433
|
render(
|
|
443
434
|
<SingleSelect
|
|
@@ -484,7 +475,7 @@ describe("SingleSelect", () => {
|
|
|
484
475
|
expect(options[0]).toHaveTextContent("item 2");
|
|
485
476
|
});
|
|
486
477
|
|
|
487
|
-
it("Type something in
|
|
478
|
+
it("Type something in SearchField should update searchText in SingleSelect", () => {
|
|
488
479
|
// Arrange
|
|
489
480
|
render(
|
|
490
481
|
<SingleSelect
|
|
@@ -611,30 +602,7 @@ describe("SingleSelect", () => {
|
|
|
611
602
|
|
|
612
603
|
// Assert
|
|
613
604
|
const dropdownMenu = screen.getByRole("listbox");
|
|
614
|
-
expect(dropdownMenu).toHaveStyle("max-height:
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
it("should apply the default maxHeight to a filterable listbox", () => {
|
|
618
|
-
// Arrange
|
|
619
|
-
|
|
620
|
-
// Act
|
|
621
|
-
render(
|
|
622
|
-
<SingleSelect
|
|
623
|
-
onChange={onChange}
|
|
624
|
-
opened={true}
|
|
625
|
-
isFilterable={true}
|
|
626
|
-
placeholder="Choose"
|
|
627
|
-
selectedValue="2"
|
|
628
|
-
>
|
|
629
|
-
<OptionItem label="item 1" value="1" />
|
|
630
|
-
<OptionItem label="item 2" value="2" />
|
|
631
|
-
<OptionItem label="item 3" value="3" />
|
|
632
|
-
</SingleSelect>,
|
|
633
|
-
);
|
|
634
|
-
|
|
635
|
-
// Assert
|
|
636
|
-
const dropdownMenu = screen.getByRole("listbox");
|
|
637
|
-
expect(dropdownMenu).toHaveStyle("max-height: 184px");
|
|
605
|
+
expect(dropdownMenu).toHaveStyle("max-height: 120px");
|
|
638
606
|
});
|
|
639
607
|
|
|
640
608
|
it("should apply the default maxHeight to a virtualized listbox", () => {
|
|
@@ -665,7 +633,7 @@ describe("SingleSelect", () => {
|
|
|
665
633
|
// Assert
|
|
666
634
|
const dropdownMenu = screen.getByRole("listbox");
|
|
667
635
|
// Max allowed height
|
|
668
|
-
expect(dropdownMenu).toHaveStyle("max-height:
|
|
636
|
+
expect(dropdownMenu).toHaveStyle("max-height: 360px");
|
|
669
637
|
});
|
|
670
638
|
|
|
671
639
|
it("should override the default maxHeight to the listbox if a custom dropdownStyle is set", () => {
|
|
@@ -688,13 +656,13 @@ describe("SingleSelect", () => {
|
|
|
688
656
|
);
|
|
689
657
|
|
|
690
658
|
// Assert
|
|
691
|
-
const dropdownMenu = screen.
|
|
659
|
+
const dropdownMenu = screen.getByTestId("dropdown-core-container");
|
|
692
660
|
expect(dropdownMenu).toHaveStyle("max-height: 200px");
|
|
693
661
|
});
|
|
694
662
|
});
|
|
695
663
|
|
|
696
664
|
describe("a11y > Live region", () => {
|
|
697
|
-
it("should change the number of options after using the search filter",
|
|
665
|
+
it("should change the number of options after using the search filter", () => {
|
|
698
666
|
// Arrange
|
|
699
667
|
render(
|
|
700
668
|
<SingleSelect
|
|
@@ -103,6 +103,17 @@ type DefaultProps = {|
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* A menu that consists of various types of items.
|
|
106
|
+
*
|
|
107
|
+
* ## Usage
|
|
108
|
+
*
|
|
109
|
+
* ```jsx
|
|
110
|
+
* import {ActionMenu, ActionItem} from "@khanacademy/wonder-blocks-dropdown";
|
|
111
|
+
*
|
|
112
|
+
* <ActionMenu menuText="Menu">
|
|
113
|
+
* <ActionItem href="/profile" label="Profile" />
|
|
114
|
+
* <ActionItem label="Settings" onClick={() => {}} />
|
|
115
|
+
* </ActionMenu>
|
|
116
|
+
* ```
|
|
106
117
|
*/
|
|
107
118
|
export default class ActionMenu extends React.Component<Props, State> {
|
|
108
119
|
openerElement: ?HTMLElement;
|
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
WithoutActionScheduler,
|
|
10
10
|
} from "@khanacademy/wonder-blocks-timing";
|
|
11
11
|
import DropdownVirtualizedItem from "./dropdown-core-virtualized-item.js";
|
|
12
|
-
import SearchTextInput from "./search-text-input.js";
|
|
13
12
|
import SeparatorItem from "./separator-item.js";
|
|
14
13
|
|
|
15
14
|
import type {DropdownItem} from "../util/types.js";
|
|
@@ -17,7 +16,6 @@ import type {DropdownItem} from "../util/types.js";
|
|
|
17
16
|
import {
|
|
18
17
|
DROPDOWN_ITEM_HEIGHT,
|
|
19
18
|
MAX_VISIBLE_ITEMS,
|
|
20
|
-
SEARCH_ITEM_HEIGHT,
|
|
21
19
|
SEPARATOR_ITEM_HEIGHT,
|
|
22
20
|
} from "../util/constants.js";
|
|
23
21
|
import {getDropdownMenuHeight} from "../util/dropdown-menu-styles.js";
|
|
@@ -134,9 +132,6 @@ class DropdownCoreVirtualized extends React.Component<Props, State> {
|
|
|
134
132
|
if (SeparatorItem.isClassOf(item.component)) {
|
|
135
133
|
// this is the separator's height (1px) + vertical margin (8px)
|
|
136
134
|
return SEPARATOR_ITEM_HEIGHT;
|
|
137
|
-
} else if (SearchTextInput.isClassOf(item.component)) {
|
|
138
|
-
// search text input height
|
|
139
|
-
return SEARCH_ITEM_HEIGHT;
|
|
140
135
|
} else {
|
|
141
136
|
// default dropdown item height
|
|
142
137
|
return DROPDOWN_ITEM_HEIGHT;
|
|
@@ -11,6 +11,7 @@ import Color, {fade} from "@khanacademy/wonder-blocks-color";
|
|
|
11
11
|
|
|
12
12
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
13
13
|
import {addStyle, View} from "@khanacademy/wonder-blocks-core";
|
|
14
|
+
import SearchField from "@khanacademy/wonder-blocks-search-field";
|
|
14
15
|
import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
|
|
15
16
|
import {withActionScheduler} from "@khanacademy/wonder-blocks-timing";
|
|
16
17
|
|
|
@@ -21,8 +22,7 @@ import type {
|
|
|
21
22
|
} from "@khanacademy/wonder-blocks-timing";
|
|
22
23
|
import DropdownCoreVirtualized from "./dropdown-core-virtualized.js";
|
|
23
24
|
import SeparatorItem from "./separator-item.js";
|
|
24
|
-
import
|
|
25
|
-
import {defaultLabels, keyCodes, searchInputStyle} from "../util/constants.js";
|
|
25
|
+
import {defaultLabels, keyCodes} from "../util/constants.js";
|
|
26
26
|
import type {DropdownItem} from "../util/types.js";
|
|
27
27
|
import DropdownPopper from "./dropdown-popper.js";
|
|
28
28
|
import {
|
|
@@ -45,6 +45,19 @@ const VIRTUALIZE_THRESHOLD = 125;
|
|
|
45
45
|
const StyledSpan = addStyle("span");
|
|
46
46
|
|
|
47
47
|
type Labels = {|
|
|
48
|
+
/**
|
|
49
|
+
* Label for describing the dismiss icon on the search filter.
|
|
50
|
+
*/
|
|
51
|
+
clearSearch: string,
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Label for the search placeholder.
|
|
55
|
+
*/
|
|
56
|
+
filter: string,
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Label for when the filter returns no results.
|
|
60
|
+
*/
|
|
48
61
|
noResults: string,
|
|
49
62
|
/**
|
|
50
63
|
* The number total of items available.
|
|
@@ -95,15 +108,15 @@ type Props = {|
|
|
|
95
108
|
|
|
96
109
|
/**
|
|
97
110
|
* An optional handler to set the searchText of the parent. When this and
|
|
98
|
-
* the searchText exist,
|
|
99
|
-
*
|
|
111
|
+
* the searchText exist, SearchField will be displayed at the top of the
|
|
112
|
+
* dropdown body.
|
|
100
113
|
*/
|
|
101
114
|
onSearchTextChanged?: ?(searchText: string) => mixed,
|
|
102
115
|
|
|
103
116
|
/**
|
|
104
117
|
* An optional string that the user entered to search the items. When this
|
|
105
|
-
* and the onSearchTextChanged exist,
|
|
106
|
-
*
|
|
118
|
+
* and the onSearchTextChanged exist, SearchField will be displayed at the
|
|
119
|
+
* top of the dropdown body.
|
|
107
120
|
*/
|
|
108
121
|
searchText?: ?string,
|
|
109
122
|
|
|
@@ -149,6 +162,12 @@ type Props = {|
|
|
|
149
162
|
*/
|
|
150
163
|
role: DropdownAriaRole,
|
|
151
164
|
|
|
165
|
+
/**
|
|
166
|
+
* When this is true, the dropdown body shows a search text input at the
|
|
167
|
+
* top. The items will be filtered by the input.
|
|
168
|
+
*/
|
|
169
|
+
isFilterable?: boolean,
|
|
170
|
+
|
|
152
171
|
...WithActionSchedulerProps,
|
|
153
172
|
|};
|
|
154
173
|
|
|
@@ -159,6 +178,11 @@ type State = {|
|
|
|
159
178
|
*/
|
|
160
179
|
itemRefs: Array<{|ref: {|current: any|}, originalIndex: number|}>,
|
|
161
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The object containing the custom labels used inside this component.
|
|
183
|
+
*/
|
|
184
|
+
labels: Labels,
|
|
185
|
+
|
|
162
186
|
/**
|
|
163
187
|
* Because getDerivedStateFromProps doesn't store previous props (in the
|
|
164
188
|
* spirit of performance), we store the previous items just to be able to
|
|
@@ -172,11 +196,6 @@ type State = {|
|
|
|
172
196
|
* resetting focusedIndex and focusedOriginalIndex when an update happens.
|
|
173
197
|
*/
|
|
174
198
|
sameItemsFocusable: boolean,
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* The object containing the custom labels used inside this component.
|
|
178
|
-
*/
|
|
179
|
-
labels: Labels,
|
|
180
199
|
|};
|
|
181
200
|
|
|
182
201
|
/**
|
|
@@ -220,6 +239,8 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
220
239
|
static defaultProps: DefaultProps = {
|
|
221
240
|
alignment: "left",
|
|
222
241
|
labels: {
|
|
242
|
+
clearSearch: defaultLabels.clearSearch,
|
|
243
|
+
filter: defaultLabels.filter,
|
|
223
244
|
noResults: defaultLabels.noResults,
|
|
224
245
|
someSelected: defaultLabels.someSelected,
|
|
225
246
|
},
|
|
@@ -333,31 +354,21 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
333
354
|
this.removeEventListeners();
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
|
|
337
|
-
return (
|
|
338
|
-
!!this.props.onSearchTextChanged &&
|
|
339
|
-
typeof this.props.searchText === "string"
|
|
340
|
-
);
|
|
341
|
-
}
|
|
357
|
+
searchFieldRef: {|current: null | HTMLInputElement|} = React.createRef();
|
|
342
358
|
|
|
343
|
-
// Resets our initial focus index to what was passed in
|
|
344
|
-
|
|
345
|
-
resetFocusedIndex() {
|
|
359
|
+
// Resets our initial focus index to what was passed in via the props
|
|
360
|
+
resetFocusedIndex(): void {
|
|
346
361
|
const {initialFocusedIndex} = this.props;
|
|
347
362
|
|
|
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
|
-
}
|
|
363
|
+
// If we are given an initial focus index, select it. Otherwise default
|
|
364
|
+
// to the first item
|
|
365
|
+
if (typeof initialFocusedIndex !== "undefined") {
|
|
366
|
+
this.focusedIndex = initialFocusedIndex;
|
|
360
367
|
} else {
|
|
368
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
369
|
+
return this.focusSearchField();
|
|
370
|
+
}
|
|
371
|
+
|
|
361
372
|
this.focusedIndex = 0;
|
|
362
373
|
}
|
|
363
374
|
}
|
|
@@ -419,9 +430,9 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
419
430
|
}
|
|
420
431
|
|
|
421
432
|
focusCurrentItem() {
|
|
422
|
-
const
|
|
433
|
+
const focusedItemRef = this.state.itemRefs[this.focusedIndex];
|
|
423
434
|
|
|
424
|
-
if (
|
|
435
|
+
if (focusedItemRef) {
|
|
425
436
|
// force react-window to scroll to ensure the focused item is visible
|
|
426
437
|
if (this.virtualizedListRef.current) {
|
|
427
438
|
// Our focused index does not include disabled items, but the
|
|
@@ -429,24 +440,45 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
429
440
|
// in the count. So we need to use "originalIndex", which
|
|
430
441
|
// does account for disabled items.
|
|
431
442
|
this.virtualizedListRef.current.scrollToItem(
|
|
432
|
-
|
|
443
|
+
focusedItemRef.originalIndex,
|
|
433
444
|
);
|
|
434
445
|
}
|
|
435
446
|
|
|
436
447
|
const node = ((ReactDOM.findDOMNode(
|
|
437
|
-
|
|
448
|
+
focusedItemRef.ref.current,
|
|
438
449
|
): any): HTMLElement);
|
|
439
450
|
if (node) {
|
|
440
451
|
node.focus();
|
|
441
452
|
// Keep track of the original index of the newly focused item.
|
|
442
453
|
// To be used if the set of focusable items in the menu changes
|
|
443
|
-
this.focusedOriginalIndex =
|
|
454
|
+
this.focusedOriginalIndex = focusedItemRef.originalIndex;
|
|
444
455
|
}
|
|
445
456
|
}
|
|
446
457
|
}
|
|
447
458
|
|
|
448
|
-
|
|
459
|
+
focusSearchField() {
|
|
460
|
+
if (this.searchFieldRef.current) {
|
|
461
|
+
this.searchFieldRef.current.focus();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
hasSearchField(): boolean {
|
|
466
|
+
return !!this.props.isFilterable;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
isSearchFieldFocused(): boolean {
|
|
470
|
+
return (
|
|
471
|
+
this.hasSearchField() &&
|
|
472
|
+
document.activeElement === this.searchFieldRef.current
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
focusPreviousItem(): void {
|
|
449
477
|
if (this.focusedIndex === 0) {
|
|
478
|
+
// Move the focus to the search field if it is the first item.
|
|
479
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
480
|
+
return this.focusSearchField();
|
|
481
|
+
}
|
|
450
482
|
this.focusedIndex = this.state.itemRefs.length - 1;
|
|
451
483
|
} else {
|
|
452
484
|
this.focusedIndex -= 1;
|
|
@@ -455,8 +487,12 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
455
487
|
this.scheduleToFocusCurrentItem();
|
|
456
488
|
}
|
|
457
489
|
|
|
458
|
-
focusNextItem() {
|
|
490
|
+
focusNextItem(): void {
|
|
459
491
|
if (this.focusedIndex === this.state.itemRefs.length - 1) {
|
|
492
|
+
// Move the focus to the search field if it is the last item.
|
|
493
|
+
if (this.hasSearchField() && !this.isSearchFieldFocused()) {
|
|
494
|
+
return this.focusSearchField();
|
|
495
|
+
}
|
|
460
496
|
this.focusedIndex = 0;
|
|
461
497
|
} else {
|
|
462
498
|
this.focusedIndex += 1;
|
|
@@ -491,24 +527,20 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
491
527
|
// Handle all other key behavior
|
|
492
528
|
switch (keyCode) {
|
|
493
529
|
case keyCodes.tab:
|
|
494
|
-
// When we show
|
|
530
|
+
// When we show SearchField and that is focused and the
|
|
495
531
|
// 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
|
-
) {
|
|
532
|
+
// is displayed. When user presses tab, we should move focus to
|
|
533
|
+
// the dismiss button.
|
|
534
|
+
if (this.isSearchFieldFocused() && searchText) {
|
|
503
535
|
return;
|
|
504
536
|
}
|
|
505
537
|
this.restoreTabOrder();
|
|
506
538
|
onOpenChanged(false);
|
|
507
539
|
return;
|
|
508
540
|
case keyCodes.space:
|
|
509
|
-
// When we display
|
|
510
|
-
//
|
|
511
|
-
if (this.
|
|
541
|
+
// When we display SearchField and the focus is on it, we should
|
|
542
|
+
// let the user type space.
|
|
543
|
+
if (this.isSearchFieldFocused()) {
|
|
512
544
|
return;
|
|
513
545
|
}
|
|
514
546
|
// Prevent space from scrolling down the page
|
|
@@ -531,9 +563,9 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
531
563
|
const keyCode = event.which || event.keyCode;
|
|
532
564
|
switch (keyCode) {
|
|
533
565
|
case keyCodes.space:
|
|
534
|
-
// When we display
|
|
535
|
-
//
|
|
536
|
-
if (this.
|
|
566
|
+
// When we display SearchField and the focus is on it, we should
|
|
567
|
+
// let the user type space.
|
|
568
|
+
if (this.isSearchFieldFocused()) {
|
|
537
569
|
return;
|
|
538
570
|
}
|
|
539
571
|
// Prevent space from scrolling down the page
|
|
@@ -588,17 +620,11 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
588
620
|
maybeRenderNoResults(): React.Node {
|
|
589
621
|
const {
|
|
590
622
|
items,
|
|
591
|
-
onSearchTextChanged,
|
|
592
|
-
searchText,
|
|
593
623
|
labels: {noResults},
|
|
594
624
|
} = this.props;
|
|
595
|
-
const showSearchTextInput =
|
|
596
|
-
!!onSearchTextChanged && typeof searchText === "string";
|
|
597
|
-
|
|
598
|
-
const includeSearchCount = showSearchTextInput ? 1 : 0;
|
|
599
625
|
|
|
600
626
|
// Verify if there are items to be rendered or not
|
|
601
|
-
const numResults = items.length
|
|
627
|
+
const numResults = items.length;
|
|
602
628
|
|
|
603
629
|
if (numResults === 0) {
|
|
604
630
|
return (
|
|
@@ -664,30 +690,6 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
664
690
|
? this.state.itemRefs[focusIndex].ref
|
|
665
691
|
: null;
|
|
666
692
|
|
|
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
693
|
// Render OptionItem and/or ActionItem elements.
|
|
692
694
|
return React.cloneElement(component, {
|
|
693
695
|
...populatedProps,
|
|
@@ -721,30 +723,6 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
721
723
|
|
|
722
724
|
const focusIndex = focusCounter - 1;
|
|
723
725
|
|
|
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
726
|
return {
|
|
749
727
|
...item,
|
|
750
728
|
role: itemRole,
|
|
@@ -774,6 +752,32 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
774
752
|
);
|
|
775
753
|
}
|
|
776
754
|
|
|
755
|
+
handleSearchTextChanged: (searchText: string) => void = (
|
|
756
|
+
searchText: string,
|
|
757
|
+
) => {
|
|
758
|
+
const {onSearchTextChanged} = this.props;
|
|
759
|
+
|
|
760
|
+
if (onSearchTextChanged) {
|
|
761
|
+
onSearchTextChanged(searchText);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
renderSearchField(): React.Node {
|
|
766
|
+
const {searchText} = this.props;
|
|
767
|
+
const {labels} = this.state;
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<SearchField
|
|
771
|
+
clearAriaLabel={labels.clearSearch}
|
|
772
|
+
onChange={this.handleSearchTextChanged}
|
|
773
|
+
placeholder={labels.filter}
|
|
774
|
+
ref={this.searchFieldRef}
|
|
775
|
+
style={styles.searchInputStyle}
|
|
776
|
+
value={searchText || ""}
|
|
777
|
+
/>
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
777
781
|
renderDropdownMenu(
|
|
778
782
|
listRenderer: React.Node,
|
|
779
783
|
isReferenceHidden: ?boolean,
|
|
@@ -788,33 +792,34 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
788
792
|
? openerStyle.getPropertyValue("width")
|
|
789
793
|
: 0;
|
|
790
794
|
|
|
791
|
-
|
|
792
|
-
const initialHeight = 12;
|
|
793
|
-
|
|
794
|
-
const maxDropdownHeight = getDropdownMenuHeight(
|
|
795
|
-
this.props.items,
|
|
796
|
-
initialHeight,
|
|
797
|
-
);
|
|
795
|
+
const maxDropdownHeight = getDropdownMenuHeight(this.props.items);
|
|
798
796
|
|
|
799
797
|
return (
|
|
800
798
|
<View
|
|
801
799
|
// Stop propagation to prevent the mouseup listener on the
|
|
802
800
|
// document from closing the menu.
|
|
803
801
|
onMouseUp={this.handleDropdownMouseUp}
|
|
804
|
-
role={this.props.role}
|
|
805
802
|
style={[
|
|
806
803
|
styles.dropdown,
|
|
807
804
|
light && styles.light,
|
|
808
805
|
isReferenceHidden && styles.hidden,
|
|
809
|
-
generateDropdownMenuStyles(
|
|
810
|
-
minDropdownWidth,
|
|
811
|
-
maxDropdownHeight,
|
|
812
|
-
),
|
|
813
|
-
|
|
814
806
|
dropdownStyle,
|
|
815
807
|
]}
|
|
808
|
+
testId="dropdown-core-container"
|
|
816
809
|
>
|
|
817
|
-
{
|
|
810
|
+
{this.props.isFilterable && this.renderSearchField()}
|
|
811
|
+
<View
|
|
812
|
+
role={this.props.role}
|
|
813
|
+
style={[
|
|
814
|
+
styles.listboxOrMenu,
|
|
815
|
+
generateDropdownMenuStyles(
|
|
816
|
+
minDropdownWidth,
|
|
817
|
+
maxDropdownHeight,
|
|
818
|
+
),
|
|
819
|
+
]}
|
|
820
|
+
>
|
|
821
|
+
{listRenderer}
|
|
822
|
+
</View>
|
|
818
823
|
{this.maybeRenderNoResults()}
|
|
819
824
|
</View>
|
|
820
825
|
);
|
|
@@ -850,9 +855,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
850
855
|
renderLiveRegion(): React.Node {
|
|
851
856
|
const {items, open} = this.props;
|
|
852
857
|
const {labels} = this.state;
|
|
853
|
-
const totalItems =
|
|
854
|
-
? items.length - 1
|
|
855
|
-
: items.length;
|
|
858
|
+
const totalItems = items.length;
|
|
856
859
|
|
|
857
860
|
return (
|
|
858
861
|
<StyledSpan
|
|
@@ -897,7 +900,6 @@ const styles = StyleSheet.create({
|
|
|
897
900
|
paddingBottom: Spacing.xxxSmall_4,
|
|
898
901
|
border: `solid 1px ${Color.offBlack16}`,
|
|
899
902
|
boxShadow: `0px 8px 8px 0px ${fade(Color.offBlack, 0.1)}`,
|
|
900
|
-
overflowY: "auto",
|
|
901
903
|
},
|
|
902
904
|
|
|
903
905
|
light: {
|
|
@@ -905,6 +907,10 @@ const styles = StyleSheet.create({
|
|
|
905
907
|
border: "none",
|
|
906
908
|
},
|
|
907
909
|
|
|
910
|
+
listboxOrMenu: {
|
|
911
|
+
overflowY: "auto",
|
|
912
|
+
},
|
|
913
|
+
|
|
908
914
|
hidden: {
|
|
909
915
|
pointerEvents: "none",
|
|
910
916
|
visibility: "hidden",
|
|
@@ -916,6 +922,14 @@ const styles = StyleSheet.create({
|
|
|
916
922
|
marginTop: Spacing.xxSmall_6,
|
|
917
923
|
},
|
|
918
924
|
|
|
925
|
+
searchInputStyle: {
|
|
926
|
+
margin: Spacing.xSmall_8,
|
|
927
|
+
marginTop: Spacing.xxxSmall_4,
|
|
928
|
+
// Set `minHeight` to "auto" to stop the search field from having
|
|
929
|
+
// a height of 0 and being cut off.
|
|
930
|
+
minHeight: "auto",
|
|
931
|
+
},
|
|
932
|
+
|
|
919
933
|
srOnly: {
|
|
920
934
|
border: 0,
|
|
921
935
|
clip: "rect(0,0,0,0)",
|