@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/es/index.js +92 -162
  3. package/dist/index.js +285 -374
  4. package/package.json +6 -6
  5. package/src/components/__docs__/action-menu.argtypes.js +44 -0
  6. package/src/components/__docs__/action-menu.stories.js +435 -0
  7. package/src/components/__docs__/base-select.argtypes.js +54 -0
  8. package/src/components/__docs__/multi-select.stories.js +509 -0
  9. package/src/components/__docs__/single-select.accessibility.stories.mdx +59 -0
  10. package/src/components/__docs__/single-select.argtypes.js +54 -0
  11. package/src/components/__docs__/single-select.stories.js +464 -0
  12. package/src/components/__tests__/dropdown-core-virtualized.test.js +0 -15
  13. package/src/components/__tests__/dropdown-core.test.js +114 -208
  14. package/src/components/__tests__/multi-select.test.js +1 -3
  15. package/src/components/__tests__/single-select.test.js +15 -47
  16. package/src/components/action-menu.js +11 -0
  17. package/src/components/dropdown-core-virtualized.js +0 -5
  18. package/src/components/dropdown-core.js +140 -126
  19. package/src/components/multi-select.js +17 -33
  20. package/src/components/single-select.js +15 -30
  21. package/src/util/__tests__/dropdown-menu-styles.test.js +0 -26
  22. package/src/util/constants.js +0 -11
  23. package/src/util/dropdown-menu-styles.js +0 -5
  24. package/src/util/types.js +2 -5
  25. package/src/components/__tests__/search-text-input.test.js +0 -212
  26. package/src/components/action-menu.stories.js +0 -48
  27. package/src/components/multi-select.stories.js +0 -124
  28. package/src/components/search-text-input.js +0 -115
  29. 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.getByTestId("parent-button"));
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.getByTestId("parent-button"));
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(screen.getByTestId("parent-button"));
269
+ userEvent.click(opener);
269
270
  // click on the dropdown anchor to hide the menu
270
- userEvent.click(screen.getByText("Choose"));
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.getByTestId("custom-opener");
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.getByTestId("custom-opener");
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 SearchTextInput when isFilterable is true", () => {
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 SearchTextInput should update searchText in SingleSelect", () => {
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: 132px");
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: 384px");
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.getByRole("listbox");
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", async () => {
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 SearchTextInput from "./search-text-input.js";
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, SearchTextInput will be displayed at the top of
99
- * the dropdown body.
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, SearchTextInput will be displayed at
106
- * the top of the dropdown body.
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
- hasSearchBox(): boolean {
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
- // via the props
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. Otherwise
349
- // default to the first item
350
- if (initialFocusedIndex) {
351
- // If we have a search box visible, then our focus
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 fousedItemRef = this.state.itemRefs[this.focusedIndex];
433
+ const focusedItemRef = this.state.itemRefs[this.focusedIndex];
423
434
 
424
- if (fousedItemRef) {
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
- fousedItemRef.originalIndex,
443
+ focusedItemRef.originalIndex,
433
444
  );
434
445
  }
435
446
 
436
447
  const node = ((ReactDOM.findDOMNode(
437
- fousedItemRef.ref.current,
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 = fousedItemRef.originalIndex;
454
+ this.focusedOriginalIndex = focusedItemRef.originalIndex;
444
455
  }
445
456
  }
446
457
  }
447
458
 
448
- focusPreviousItem() {
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 SearchTextInput and that is focused and the
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
- // to the dismiss button.
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 SearchTextInput and the focus is on it,
510
- // we should let the user type space.
511
- if (this.hasSearchBox() && this.focusedIndex === 0) {
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 SearchTextInput and the focus is on it,
535
- // we should let the user type space.
536
- if (this.hasSearchBox() && this.focusedIndex === 0) {
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 - includeSearchCount;
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
- // Vertical padding of the dropdown menu + borders
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
- {listRenderer}
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 = this.hasSearchBox()
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)",