@khanacademy/wonder-blocks-dropdown 2.6.8 → 2.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-dropdown",
3
- "version": "2.6.8",
3
+ "version": "2.7.0",
4
4
  "design": "v1",
5
5
  "description": "Dropdown variants for Wonder Blocks.",
6
6
  "main": "dist/index.js",
@@ -16,17 +16,18 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@babel/runtime": "^7.16.3",
19
- "@khanacademy/wonder-blocks-button": "^2.11.4",
20
- "@khanacademy/wonder-blocks-clickable": "^2.2.5",
19
+ "@khanacademy/wonder-blocks-button": "^2.11.5",
20
+ "@khanacademy/wonder-blocks-clickable": "^2.2.6",
21
21
  "@khanacademy/wonder-blocks-color": "^1.1.20",
22
- "@khanacademy/wonder-blocks-core": "^4.3.0",
23
- "@khanacademy/wonder-blocks-icon": "^1.2.26",
24
- "@khanacademy/wonder-blocks-icon-button": "^3.4.5",
25
- "@khanacademy/wonder-blocks-layout": "^1.4.8",
26
- "@khanacademy/wonder-blocks-modal": "^2.3.0",
22
+ "@khanacademy/wonder-blocks-core": "^4.3.1",
23
+ "@khanacademy/wonder-blocks-icon": "^1.2.27",
24
+ "@khanacademy/wonder-blocks-icon-button": "^3.4.6",
25
+ "@khanacademy/wonder-blocks-layout": "^1.4.9",
26
+ "@khanacademy/wonder-blocks-modal": "^2.3.1",
27
+ "@khanacademy/wonder-blocks-search-field": "^1.0.4",
27
28
  "@khanacademy/wonder-blocks-spacing": "^3.0.5",
28
29
  "@khanacademy/wonder-blocks-timing": "^2.1.0",
29
- "@khanacademy/wonder-blocks-typography": "^1.1.30"
30
+ "@khanacademy/wonder-blocks-typography": "^1.1.31"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@popperjs/core": "^2.10.1",
@@ -39,6 +40,6 @@
39
40
  "react-window": "^1.8.5"
40
41
  },
41
42
  "devDependencies": {
42
- "wb-dev-build-settings": "^0.3.0"
43
+ "wb-dev-build-settings": "^0.4.0"
43
44
  }
44
45
  }
@@ -1,6 +1,6 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
- import {fireEvent, render, screen} from "@testing-library/react";
3
+ import {fireEvent, render, screen, waitFor} from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
 
6
6
  import OptionItem from "../option-item.js";
@@ -784,7 +784,9 @@ describe("DropdownCore", () => {
784
784
  const searchField = await screen.findByPlaceholderText("Filter");
785
785
 
786
786
  // Assert
787
- expect(searchField).toHaveFocus();
787
+ waitFor(() => {
788
+ expect(searchField).toHaveFocus();
789
+ });
788
790
  });
789
791
 
790
792
  it("should focus on the item after clicking on it", async () => {
@@ -826,7 +828,9 @@ describe("DropdownCore", () => {
826
828
  userEvent.click(item);
827
829
 
828
830
  // Assert
829
- expect(item).toHaveFocus();
831
+ waitFor(() => {
832
+ expect(item).toHaveFocus();
833
+ });
830
834
  });
831
835
  });
832
836
  });
@@ -1,14 +1,14 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
- import {shallow} from "enzyme";
4
- import "jest-enzyme";
3
+ import {render, screen} from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
5
 
6
6
  import SearchTextInput from "../search-text-input.js";
7
7
 
8
8
  describe("SearchTextInput", () => {
9
- it("text input container should be focused when focusing on the input", () => {
9
+ test("text input container should be focused when focusing on the input", () => {
10
10
  // Arrange
11
- const wrapper = shallow(
11
+ render(
12
12
  <SearchTextInput
13
13
  searchText=""
14
14
  testId="search-text-input"
@@ -16,18 +16,18 @@ describe("SearchTextInput", () => {
16
16
  />,
17
17
  );
18
18
 
19
- const input = wrapper.find(`[data-test-id="search-text-input"]`);
19
+ const input = screen.getByTestId("search-text-input");
20
20
 
21
21
  // Act
22
- input.simulate("focus");
22
+ input.focus();
23
23
 
24
24
  // Assert
25
- expect(wrapper).toHaveState({focused: true});
25
+ expect(input).toHaveFocus();
26
26
  });
27
27
 
28
- it("text input should not be focused when losing focus", () => {
28
+ test("text input should not be focused when losing focus", () => {
29
29
  // Arrange
30
- const wrapper = shallow(
30
+ render(
31
31
  <SearchTextInput
32
32
  searchText=""
33
33
  testId="search-text-input"
@@ -35,23 +35,23 @@ describe("SearchTextInput", () => {
35
35
  />,
36
36
  );
37
37
 
38
- const input = wrapper.find(`[data-test-id="search-text-input"]`);
38
+ const input = screen.getByTestId("search-text-input");
39
39
  // focus in
40
- input.simulate("focus");
40
+ input.focus();
41
41
 
42
42
  // Act
43
43
  // focus out
44
- input.simulate("blur");
44
+ input.blur();
45
45
 
46
46
  // Assert
47
- expect(wrapper).toHaveState({focused: false});
47
+ expect(input).not.toHaveFocus();
48
48
  });
49
49
 
50
- it("onChange should be invoked if text input changes", () => {
50
+ test("onChange should be invoked if text input changes", () => {
51
51
  // Arrange
52
52
  const onChangeMock = jest.fn();
53
53
 
54
- const wrapper = shallow(
54
+ render(
55
55
  <SearchTextInput
56
56
  searchText=""
57
57
  testId="search-text-input"
@@ -59,87 +59,154 @@ describe("SearchTextInput", () => {
59
59
  />,
60
60
  );
61
61
 
62
- const input = wrapper.find(`[data-test-id="search-text-input"]`);
62
+ const input = screen.getByTestId("search-text-input");
63
63
 
64
64
  // Act
65
- input.simulate("change", {
66
- target: {value: "query"},
67
- preventDefault: jest.fn(),
68
- });
69
-
70
- wrapper.update();
65
+ userEvent.paste(input, "value");
71
66
 
72
67
  // Assert
73
- expect(onChangeMock).toHaveBeenCalledWith("query");
68
+ expect(onChangeMock).toHaveBeenCalledWith("value");
74
69
  });
75
70
 
76
- it("displays the dismiss button when search text exists", () => {
71
+ test("displays the dismiss button when search text exists", () => {
77
72
  // Arrange
78
- const wrapper = shallow(
79
- <SearchTextInput
80
- searchText="query"
81
- testId="search-text-input"
82
- onChange={() => jest.fn()}
83
- onClick={() => jest.fn()}
84
- />,
85
- );
73
+ const SearchFieldWrapper = () => {
74
+ const [value, setValue] = React.useState("");
75
+ return (
76
+ <SearchTextInput
77
+ searchText={value}
78
+ testId="search-text-input"
79
+ onChange={setValue}
80
+ />
81
+ );
82
+ };
83
+
84
+ render(<SearchFieldWrapper />);
85
+
86
+ const input = screen.getByTestId("search-text-input");
87
+
88
+ // Act
89
+ userEvent.paste(input, "value");
90
+
91
+ // Assert
92
+ const clearIconButton = screen.queryByRole("button");
93
+ expect(clearIconButton).toBeInTheDocument();
94
+ });
86
95
 
87
- const input = wrapper.find(`[data-test-id="search-text-input"]`);
96
+ test("search should be cleared if the clear icon is clicked", () => {
97
+ // Arrange
98
+ const SearchFieldWrapper = () => {
99
+ const [value, setValue] = React.useState("initial value");
100
+ return (
101
+ <SearchTextInput
102
+ searchText={value}
103
+ testId="search-text-input"
104
+ onChange={setValue}
105
+ />
106
+ );
107
+ };
108
+
109
+ render(<SearchFieldWrapper />);
110
+
111
+ const input = screen.getByTestId("search-text-input");
112
+ const clearIconButton = screen.queryByRole("button");
88
113
 
89
114
  // Act
90
- input.simulate("change", {
91
- target: {value: "query"},
92
- preventDefault: jest.fn(),
93
- });
115
+ userEvent.click(clearIconButton);
94
116
 
95
117
  // Assert
96
- expect(wrapper.find("IconButton")).toExist();
118
+ expect(input).toHaveValue("");
97
119
  });
98
120
 
99
- it("search should be dismissed if the close icon is clicked", () => {
121
+ test("focus should return to the input element after clear button is clicked", () => {
100
122
  // Arrange
101
- const onClickMock = jest.fn();
123
+ const SearchFieldWrapper = () => {
124
+ const [value, setValue] = React.useState("");
125
+ return (
126
+ <SearchTextInput
127
+ searchText={value}
128
+ testId="search-text-input"
129
+ onChange={setValue}
130
+ />
131
+ );
132
+ };
133
+
134
+ render(<SearchFieldWrapper />);
135
+
136
+ const input = screen.getByTestId("search-text-input");
137
+ userEvent.paste(input, "something");
102
138
 
103
- const wrapper = shallow(
139
+ // Act
140
+ const clearIconButton = screen.queryByRole("button");
141
+ userEvent.click(clearIconButton);
142
+
143
+ // Assert
144
+ expect(input).toHaveFocus();
145
+ });
146
+
147
+ test("placeholder should be updated by the parent component", () => {
148
+ // Arrange
149
+ const {rerender} = render(
104
150
  <SearchTextInput
105
151
  searchText="query"
106
- onChange={() => jest.fn()}
107
- onClick={onClickMock}
152
+ onChange={() => {}}
153
+ labels={{
154
+ clearSearch: "Clear",
155
+ filter: "Filter",
156
+ }}
157
+ testId="search-text-input"
108
158
  />,
109
159
  );
110
160
 
111
- const dismissBtn = wrapper.find("IconButton");
161
+ const input = screen.getByTestId("search-text-input");
112
162
 
113
163
  // Act
114
- dismissBtn.simulate("click");
115
-
116
- wrapper.update();
164
+ rerender(
165
+ <SearchTextInput
166
+ searchText="query"
167
+ onChange={() => {}}
168
+ labels={{
169
+ clearSearch: "Dismiss",
170
+ filter: "Search",
171
+ }}
172
+ testId="search-text-input"
173
+ />,
174
+ );
117
175
 
118
176
  // Assert
119
- expect(onClickMock).toHaveBeenCalled();
177
+ expect(input).toHaveAttribute("placeholder", "Search");
120
178
  });
121
179
 
122
- it("labels should be updated by the parent component", () => {
180
+ test("button label should be updated by the parent component", () => {
123
181
  // Arrange
124
- const wrapper = shallow(
182
+ const {rerender} = render(
125
183
  <SearchTextInput
126
184
  searchText="query"
127
- onChange={() => jest.fn()}
185
+ onChange={() => {}}
128
186
  labels={{
129
187
  clearSearch: "Clear",
130
188
  filter: "Filter",
131
189
  }}
190
+ testId="search-text-input"
132
191
  />,
133
192
  );
134
193
 
135
- // Act
136
- wrapper.setProps({labels: {clearSearch: "Dismiss", filter: "Search"}});
194
+ const clearIconButton = screen.queryByRole("button");
137
195
 
138
- wrapper.update();
196
+ // Act
197
+ rerender(
198
+ <SearchTextInput
199
+ searchText="query"
200
+ onChange={() => {}}
201
+ labels={{
202
+ clearSearch: "Dismiss",
203
+ filter: "Search",
204
+ }}
205
+ testId="search-text-input"
206
+ />,
207
+ );
139
208
 
140
209
  // Assert
141
- expect(wrapper).toHaveState({
142
- labels: {clearSearch: "Dismiss", filter: "Search"},
143
- });
210
+ expect(clearIconButton).toHaveAttribute("aria-label", "Dismiss");
144
211
  });
145
212
  });
@@ -428,7 +428,6 @@ describe("SingleSelect", () => {
428
428
  // open dropdown
429
429
  userEvent.click(opener);
430
430
  userEvent.click(screen.getByText("Toggle B"));
431
- jest.advanceTimersByTime(100);
432
431
 
433
432
  // Assert
434
433
  // NOTE: the opener text is only updated in response to changes to the
@@ -591,4 +590,106 @@ describe("SingleSelect", () => {
591
590
  expect(dismissBtn).toHaveFocus();
592
591
  });
593
592
  });
593
+
594
+ describe("Custom listbox styles", () => {
595
+ it("should apply the default maxHeight to the listbox", () => {
596
+ // Arrange
597
+
598
+ // Act
599
+ render(
600
+ <SingleSelect
601
+ onChange={onChange}
602
+ opened={true}
603
+ placeholder="Choose"
604
+ selectedValue="2"
605
+ >
606
+ <OptionItem label="item 1" value="1" />
607
+ <OptionItem label="item 2" value="2" />
608
+ <OptionItem label="item 3" value="3" />
609
+ </SingleSelect>,
610
+ );
611
+
612
+ // Assert
613
+ 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");
638
+ });
639
+
640
+ it("should apply the default maxHeight to a virtualized listbox", () => {
641
+ // Arrange
642
+ const optionItems = new Array(1000)
643
+ .fill(null)
644
+ .map((_, i) => (
645
+ <OptionItem
646
+ key={i}
647
+ value={(i + 1).toString()}
648
+ label={`item ${i + 1}`}
649
+ />
650
+ ));
651
+
652
+ // Act
653
+ render(
654
+ <SingleSelect
655
+ onChange={onChange}
656
+ opened={true}
657
+ isFilterable={true}
658
+ placeholder="Choose"
659
+ selectedValue="2"
660
+ >
661
+ {optionItems}
662
+ </SingleSelect>,
663
+ );
664
+
665
+ // Assert
666
+ const dropdownMenu = screen.getByRole("listbox");
667
+ // Max allowed height
668
+ expect(dropdownMenu).toHaveStyle("max-height: 384px");
669
+ });
670
+
671
+ it("should override the default maxHeight to the listbox if a custom dropdownStyle is set", () => {
672
+ // Arrange
673
+ const customMaxHeight = 200;
674
+
675
+ // Act
676
+ render(
677
+ <SingleSelect
678
+ onChange={onChange}
679
+ opened={true}
680
+ placeholder="Choose"
681
+ selectedValue="2"
682
+ dropdownStyle={{maxHeight: customMaxHeight}}
683
+ >
684
+ <OptionItem label="item 1" value="1" />
685
+ <OptionItem label="item 2" value="2" />
686
+ <OptionItem label="item 3" value="3" />
687
+ </SingleSelect>,
688
+ );
689
+
690
+ // Assert
691
+ const dropdownMenu = screen.getByRole("listbox");
692
+ expect(dropdownMenu).toHaveStyle("max-height: 200px");
693
+ });
694
+ });
594
695
  });
@@ -16,9 +16,11 @@ import type {DropdownItem} from "../util/types.js";
16
16
 
17
17
  import {
18
18
  DROPDOWN_ITEM_HEIGHT,
19
+ MAX_VISIBLE_ITEMS,
19
20
  SEARCH_ITEM_HEIGHT,
20
21
  SEPARATOR_ITEM_HEIGHT,
21
22
  } from "../util/constants.js";
23
+ import {getDropdownMenuHeight} from "../util/dropdown-menu-styles.js";
22
24
 
23
25
  type Props = {|
24
26
  /**
@@ -56,23 +58,19 @@ type State = {|
56
58
  height: ?number,
57
59
  |};
58
60
 
59
- /**
60
- * Maximum visible items inside the dropdown list.
61
- * Based on the defined height that we're using, this is the maximium
62
- * number of items that can fit into the visible porition of the
63
- * dropdowns list box.
64
- */
65
- const MAX_VISIBLE_ITEMS = 9;
66
-
67
61
  /**
68
62
  * A react-window's List wrapper that instantiates the virtualized list and
69
63
  * dynamically calculates the item height depending on the type
70
64
  */
71
65
  class DropdownCoreVirtualized extends React.Component<Props, State> {
72
- state: State = {
73
- height: this.getHeight(),
74
- width: this.props.width,
75
- };
66
+ constructor(props: Props) {
67
+ super(props);
68
+
69
+ this.state = {
70
+ height: getDropdownMenuHeight(props.data),
71
+ width: props.width,
72
+ };
73
+ }
76
74
 
77
75
  componentDidMount() {
78
76
  const {schedule} = this.props;
@@ -122,31 +120,10 @@ class DropdownCoreVirtualized extends React.Component<Props, State> {
122
120
  */
123
121
  setHeight() {
124
122
  // calculate dropdown's height depending on the type of items
125
- const height = this.getHeight();
123
+ const height = getDropdownMenuHeight(this.props.data);
126
124
  this.setState({height});
127
125
  }
128
126
 
129
- /**
130
- * The list height that is automatically calculated depending on the
131
- * component's type of each item (e.g. Separator, Option, Search, etc)
132
- */
133
- getHeight(): number {
134
- // calculate using the first 10 items on the array as we want to display
135
- // this number of elements in the visible area
136
- return this.props.data
137
- .slice(0, MAX_VISIBLE_ITEMS)
138
- .reduce((sum, item) => {
139
- if (SeparatorItem.isClassOf(item.component)) {
140
- return sum + SEPARATOR_ITEM_HEIGHT;
141
- } else if (SearchTextInput.isClassOf(item.component)) {
142
- // search text input height
143
- return sum + SEARCH_ITEM_HEIGHT;
144
- } else {
145
- return sum + DROPDOWN_ITEM_HEIGHT;
146
- }
147
- }, 0);
148
- }
149
-
150
127
  /**
151
128
  * Calculates item height
152
129
  */
@@ -25,6 +25,10 @@ import SearchTextInput from "./search-text-input.js";
25
25
  import {defaultLabels, keyCodes, searchInputStyle} from "../util/constants.js";
26
26
  import type {DropdownItem} from "../util/types.js";
27
27
  import DropdownPopper from "./dropdown-popper.js";
28
+ import {
29
+ generateDropdownMenuStyles,
30
+ getDropdownMenuHeight,
31
+ } from "../util/dropdown-menu-styles.js";
28
32
 
29
33
  /**
30
34
  * The number of options to apply the virtualized list to.
@@ -762,6 +766,14 @@ class DropdownCore extends React.Component<Props, State> {
762
766
  ? openerStyle.getPropertyValue("width")
763
767
  : 0;
764
768
 
769
+ // Vertical padding of the dropdown menu + borders
770
+ const initialHeight = 12;
771
+
772
+ const maxDropdownHeight = getDropdownMenuHeight(
773
+ this.props.items,
774
+ initialHeight,
775
+ );
776
+
765
777
  return (
766
778
  <View
767
779
  // Stop propagation to prevent the mouseup listener on the
@@ -772,7 +784,11 @@ class DropdownCore extends React.Component<Props, State> {
772
784
  styles.dropdown,
773
785
  light && styles.light,
774
786
  isReferenceHidden && styles.hidden,
775
- {minWidth: minDropdownWidth},
787
+ generateDropdownMenuStyles(
788
+ minDropdownWidth,
789
+ maxDropdownHeight,
790
+ ),
791
+
776
792
  dropdownStyle,
777
793
  ]}
778
794
  >