@khanacademy/wonder-blocks-dropdown 2.6.10 → 2.7.2

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.
@@ -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
  });
@@ -669,6 +669,12 @@ class DropdownCore extends React.Component<Props, State> {
669
669
  },
670
670
  // apply custom styles
671
671
  style: searchInputStyle,
672
+ // TODO(WB-1310): Remove the autofocus prop after making
673
+ // the search field sticky.
674
+ // Currently autofocusing on the search field to work
675
+ // around it losing focus on mount when switching between
676
+ // virtualized and non-virtualized dropdown filter results.
677
+ autofocus: this.focusedIndex === 0,
672
678
  });
673
679
  }
674
680
 
@@ -719,6 +725,12 @@ class DropdownCore extends React.Component<Props, State> {
719
725
  itemRef: this.state.itemRefs[focusIndex]
720
726
  ? this.state.itemRefs[focusIndex].ref
721
727
  : null,
728
+ // TODO(WB-1310): Remove the autofocus prop after making
729
+ // the search field sticky.
730
+ // Currently autofocusing on the search field to work
731
+ // around it losing focus on mount when switching between
732
+ // virtualized and non-virtualized dropdown filter results.
733
+ autofocus: this.focusedIndex === 0,
722
734
  },
723
735
  };
724
736
  }
@@ -2,17 +2,11 @@
2
2
  // A TextField with a search icon on its left side and X icon on its right side
3
3
 
4
4
  import * as React from "react";
5
- import {StyleSheet, css} from "aphrodite";
6
-
7
- import {styles as typographyStyles} from "@khanacademy/wonder-blocks-typography";
8
- import {View} from "@khanacademy/wonder-blocks-core";
9
- import IconButton from "@khanacademy/wonder-blocks-icon-button";
10
- import Icon, {icons} from "@khanacademy/wonder-blocks-icon";
11
- import Color from "@khanacademy/wonder-blocks-color";
12
- import Spacing from "@khanacademy/wonder-blocks-spacing";
5
+
6
+ import SearchField from "@khanacademy/wonder-blocks-search-field";
13
7
  import type {StyleType} from "@khanacademy/wonder-blocks-core";
14
8
 
15
- import {defaultLabels, DROPDOWN_ITEM_HEIGHT} from "../util/constants.js";
9
+ import {defaultLabels} from "../util/constants.js";
16
10
 
17
11
  type Labels = {|
18
12
  clearSearch: string,
@@ -57,22 +51,20 @@ type Props = {|
57
51
  * Test ID used for e2e testing.
58
52
  */
59
53
  testId?: string,
54
+
55
+ /**
56
+ * Automatically focus on this search field on mount.
57
+ * TODO(WB-1310): Remove the autofocus prop after making
58
+ * the search field sticky in dropdowns.
59
+ */
60
+ autofocus?: boolean,
60
61
  |};
61
62
 
62
63
  type DefaultProps = {|
63
64
  labels: $PropertyType<Props, "labels">,
64
65
  |};
65
66
 
66
- type State = {|
67
- focused: boolean,
68
-
69
- /**
70
- * The object containing the custom labels used inside this component.
71
- */
72
- labels: Labels,
73
- |};
74
-
75
- export default class SearchTextInput extends React.Component<Props, State> {
67
+ export default class SearchTextInput extends React.Component<Props> {
76
68
  static isClassOf(instance: React.Element<any>): boolean {
77
69
  return (
78
70
  instance && instance.type && instance.type.__IS_SEARCH_TEXT_INPUT__
@@ -86,142 +78,38 @@ export default class SearchTextInput extends React.Component<Props, State> {
86
78
  },
87
79
  };
88
80
 
89
- state: State = {
90
- focused: false,
91
- labels: {
92
- clearSearch: defaultLabels.clearSearch,
93
- filter: defaultLabels.filter,
94
- ...this.props.labels,
95
- },
96
- };
97
-
98
- componentDidUpdate(prevProps: Props) {
99
- if (this.props.labels !== prevProps.labels) {
100
- // eslint-disable-next-line react/no-did-update-set-state
101
- this.setState({
102
- labels: {...this.state.labels, ...this.props.labels},
103
- });
81
+ // TODO(WB-1310): Remove `componentDidMount` autofocus on the search field
82
+ // after making the search field sticky.
83
+ componentDidMount() {
84
+ // We need to re-focus on the text input after it mounts because of
85
+ // the case in which the dropdown switches between virtualized and
86
+ // non-virtualized. It can rerender the search field as the user is
87
+ // typing based on the number of search results, which results
88
+ // in losing focus on the field so the user can't type anymore.
89
+ // To work around this issue, this temporary fix auto-focuses on the
90
+ // search field on mount.
91
+ if (this.props.autofocus) {
92
+ this.props.itemRef?.current.focus();
104
93
  }
105
94
  }
106
95
 
107
96
  static __IS_SEARCH_TEXT_INPUT__: boolean = true;
108
97
 
109
- handleChange: (e: SyntheticInputEvent<>) => void = (e) => {
110
- e.preventDefault();
111
- this.props.onChange(e.target.value);
112
- };
113
-
114
- handleDismiss: () => void = () => {
115
- const {onClick, onChange} = this.props;
116
- // Empty the search text and focus the SearchTextInput
117
- onChange("");
118
- if (onClick) {
119
- onClick();
120
- }
121
- };
122
-
123
- handleBlur: (e: SyntheticInputEvent<>) => void = (e) => {
124
- this.setState({focused: false});
125
- };
126
-
127
- handleFocus: (e: SyntheticInputEvent<>) => void = (e) => {
128
- this.setState({focused: true});
129
- };
130
-
131
- maybeRenderDismissIconButton(): React.Node {
132
- const {searchText} = this.props;
133
- const {clearSearch} = this.state.labels;
134
-
135
- if (searchText.length > 0) {
136
- return (
137
- <IconButton
138
- icon={icons.dismiss}
139
- kind="tertiary"
140
- onClick={this.handleDismiss}
141
- style={styles.dismissIcon}
142
- aria-label={clearSearch}
143
- />
144
- );
145
- }
146
- return null;
147
- }
148
-
149
98
  render(): React.Node {
150
- const {onClick, itemRef, searchText, style, testId} = this.props;
151
- const {filter} = this.state.labels;
99
+ const {labels, onChange, onClick, itemRef, searchText, style, testId} =
100
+ this.props;
152
101
 
153
102
  return (
154
- <View
103
+ <SearchField
104
+ clearAriaLabel={labels.clearSearch}
105
+ onChange={onChange}
155
106
  onClick={onClick}
156
- style={[
157
- styles.inputContainer,
158
- this.state.focused && styles.focused,
159
- style,
160
- ]}
161
- >
162
- <Icon
163
- icon={icons.search}
164
- size="medium"
165
- color={Color.offBlack64}
166
- style={styles.searchIcon}
167
- aria-hidden="true"
168
- />
169
- <input
170
- type="text"
171
- onChange={this.handleChange}
172
- onFocus={this.handleFocus}
173
- onBlur={this.handleBlur}
174
- ref={itemRef}
175
- placeholder={filter}
176
- value={searchText}
177
- className={css(
178
- styles.inputStyleReset,
179
- typographyStyles.LabelMedium,
180
- )}
181
- data-test-id={testId}
182
- />
183
- {this.maybeRenderDismissIconButton()}
184
- </View>
107
+ placeholder={labels.filter}
108
+ ref={itemRef}
109
+ style={style}
110
+ testId={testId}
111
+ value={searchText}
112
+ />
185
113
  );
186
114
  }
187
115
  }
188
-
189
- const styles = StyleSheet.create({
190
- inputContainer: {
191
- flexDirection: "row",
192
- border: `1px solid ${Color.offBlack16}`,
193
- borderRadius: Spacing.xxxSmall_4,
194
- alignItems: "center",
195
- // The height of the text input is 40 in design spec and we need to
196
- // specify the height as well as minHeight to make sure the search text
197
- // input takes enough height to render. (otherwise, it will get
198
- // squashed)
199
- height: DROPDOWN_ITEM_HEIGHT,
200
- minHeight: DROPDOWN_ITEM_HEIGHT,
201
- },
202
- focused: {
203
- border: `1px solid ${Color.blue}`,
204
- },
205
- searchIcon: {
206
- marginLeft: Spacing.xSmall_8,
207
- marginRight: Spacing.xSmall_8,
208
- },
209
- dismissIcon: {
210
- margin: 0,
211
- ":hover": {
212
- border: "none",
213
- },
214
- },
215
- inputStyleReset: {
216
- display: "flex",
217
- flex: 1,
218
- background: "inherit",
219
- border: "none",
220
- outline: "none",
221
- "::placeholder": {
222
- color: Color.offBlack64,
223
- },
224
- width: "100%",
225
- color: "inherit",
226
- },
227
- });
@@ -79,25 +79,28 @@ const optionItems = new Array(1000)
79
79
  ));
80
80
 
81
81
  type Props = {|
82
+ selectedValue?: ?string,
82
83
  opened: boolean,
83
84
  |};
84
85
 
85
86
  type State = {|
86
- selectedValue: string,
87
+ selectedValue?: ?string,
87
88
  opened: boolean,
88
89
  |};
89
90
 
90
91
  type DefaultProps = {|
92
+ selectedValue: $PropertyType<Props, "selectedValue">,
91
93
  opened: $PropertyType<Props, "opened">,
92
94
  |};
93
95
 
94
96
  class SingleSelectWithFilter extends React.Component<Props, State> {
95
97
  static defaultProps: DefaultProps = {
98
+ selectedValue: "2",
96
99
  opened: false,
97
100
  };
98
101
 
99
102
  state: State = {
100
- selectedValue: "2",
103
+ selectedValue: this.props.selectedValue,
101
104
  opened: this.props.opened,
102
105
  };
103
106
 
@@ -167,6 +170,10 @@ export const WithFilterOpened: StoryComponentType = () => (
167
170
  <SingleSelectWithFilter opened={true} />
168
171
  );
169
172
 
173
+ export const WithFilterOpenedNoValueSelected: StoryComponentType = () => (
174
+ <SingleSelectWithFilter opened={true} selectedValue={null} />
175
+ );
176
+
170
177
  export const DropdownInModal: StoryComponentType = () => {
171
178
  const [value, setValue] = React.useState(null);
172
179
  const [opened, setOpened] = React.useState(true);
@@ -24,6 +24,9 @@ export const filterableDropdownStyle = {
24
24
  export const searchInputStyle = {
25
25
  margin: Spacing.xSmall_8,
26
26
  marginTop: Spacing.xxxSmall_4,
27
+ // Set `minHeight` to "auto" to stop the search field from having
28
+ // a height of 0 and being cut off.
29
+ minHeight: "auto",
27
30
  };
28
31
 
29
32
  // The default item height