@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.
@@ -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,
@@ -63,16 +57,7 @@ type DefaultProps = {|
63
57
  labels: $PropertyType<Props, "labels">,
64
58
  |};
65
59
 
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> {
60
+ export default class SearchTextInput extends React.Component<Props> {
76
61
  static isClassOf(instance: React.Element<any>): boolean {
77
62
  return (
78
63
  instance && instance.type && instance.type.__IS_SEARCH_TEXT_INPUT__
@@ -86,142 +71,23 @@ export default class SearchTextInput extends React.Component<Props, State> {
86
71
  },
87
72
  };
88
73
 
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
- });
104
- }
105
- }
106
-
107
74
  static __IS_SEARCH_TEXT_INPUT__: boolean = true;
108
75
 
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
76
  render(): React.Node {
150
- const {onClick, itemRef, searchText, style, testId} = this.props;
151
- const {filter} = this.state.labels;
77
+ const {labels, onChange, onClick, itemRef, searchText, style, testId} =
78
+ this.props;
152
79
 
153
80
  return (
154
- <View
81
+ <SearchField
82
+ clearAriaLabel={labels.clearSearch}
83
+ onChange={onChange}
155
84
  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>
85
+ placeholder={labels.filter}
86
+ ref={itemRef}
87
+ style={style}
88
+ testId={testId}
89
+ value={searchText}
90
+ />
185
91
  );
186
92
  }
187
93
  }
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
- });
@@ -52,6 +52,7 @@ export const DefaultSingleSelectOpened: StoryComponentType = (args) => {
52
52
  <OptionItem label="Apple" value="apple" />
53
53
  <OptionItem label="Grape" value="grape" />
54
54
  <OptionItem label="Lemon" value="lemon" />
55
+ <OptionItem label="Mango" value="mango" />
55
56
  </SingleSelectTemplate>
56
57
  );
57
58
  };
@@ -0,0 +1,100 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import OptionItem from "../../components/option-item.js";
4
+ import SearchTextInput from "../../components/search-text-input.js";
5
+ import SeparatorItem from "../../components/separator-item.js";
6
+
7
+ import {getDropdownMenuHeight} from "../dropdown-menu-styles.js";
8
+
9
+ const optionItems = [
10
+ {
11
+ component: (
12
+ <OptionItem testId="item-0" label="item 0" value="0" key="0" />
13
+ ),
14
+ focusable: true,
15
+ populatedProps: {},
16
+ },
17
+ {
18
+ component: (
19
+ <OptionItem testId="item-1" label="item 1" value="1" key="1" />
20
+ ),
21
+ focusable: true,
22
+ populatedProps: {},
23
+ },
24
+ {
25
+ component: (
26
+ <OptionItem testId="item-2" label="item 2" value="2" key="2" />
27
+ ),
28
+ focusable: true,
29
+ populatedProps: {},
30
+ },
31
+ ];
32
+
33
+ const searchFieldItem = {
34
+ component: (
35
+ <SearchTextInput
36
+ testId="item-0"
37
+ key="search-text-input"
38
+ onChange={jest.fn()}
39
+ searchText={""}
40
+ />
41
+ ),
42
+ focusable: true,
43
+ populatedProps: {},
44
+ };
45
+
46
+ const separatorItem = {
47
+ component: <SeparatorItem />,
48
+ focusable: false,
49
+ populatedProps: {},
50
+ };
51
+
52
+ describe("getDropdownMenuHeight", () => {
53
+ it("should get a valid height for a dropdown with 3 items", () => {
54
+ // Arrange
55
+ const items = optionItems;
56
+
57
+ // Act
58
+ const height = getDropdownMenuHeight(items);
59
+
60
+ // Assert
61
+ // 3 option items
62
+ expect(height).toBe(120);
63
+ });
64
+
65
+ it("should include an initial min height", () => {
66
+ // Arrange
67
+ const items = optionItems;
68
+
69
+ // Act
70
+ const height = getDropdownMenuHeight(items, 10);
71
+
72
+ // Assert
73
+ // 3 option items + initial height (e.g. padding)
74
+ expect(height).toBe(130);
75
+ });
76
+
77
+ it("should get a valid height for a filterable dropdown", () => {
78
+ // Arrange
79
+ const items = [searchFieldItem, ...optionItems];
80
+
81
+ // Act
82
+ const height = getDropdownMenuHeight(items);
83
+
84
+ // Assert
85
+ // search field + 3 option items
86
+ expect(height).toBe(172);
87
+ });
88
+
89
+ it("should get a valid height for a dropdown with a SeparatorItem", () => {
90
+ // Arrange
91
+ const items = [separatorItem, ...optionItems];
92
+
93
+ // Act
94
+ const height = getDropdownMenuHeight(items);
95
+
96
+ // Assert
97
+ // separator item + 3 option items
98
+ expect(height).toBe(129);
99
+ });
100
+ });
@@ -19,7 +19,6 @@ export const selectDropdownStyle = {
19
19
  // Note that these can be overridden by the provided style if needed.
20
20
  export const filterableDropdownStyle = {
21
21
  minHeight: 100,
22
- maxHeight: 384,
23
22
  };
24
23
 
25
24
  export const searchInputStyle = {
@@ -30,6 +29,13 @@ export const searchInputStyle = {
30
29
  // The default item height
31
30
  export const DROPDOWN_ITEM_HEIGHT = 40;
32
31
 
32
+ /**
33
+ * Maximum visible items inside the dropdown list. Based on the defined height
34
+ * that we're using, this is the maximum number of items that can fit into the
35
+ * visible portion of the dropdown's listbox.
36
+ */
37
+ export const MAX_VISIBLE_ITEMS = 9;
38
+
33
39
  export const SEPARATOR_ITEM_HEIGHT = 9;
34
40
 
35
41
  export const SEARCH_ITEM_HEIGHT: number =
@@ -0,0 +1,65 @@
1
+ // @flow
2
+ import {StyleSheet} from "aphrodite";
3
+
4
+ import type {StyleType} from "@khanacademy/wonder-blocks-core";
5
+
6
+ import {
7
+ DROPDOWN_ITEM_HEIGHT,
8
+ MAX_VISIBLE_ITEMS,
9
+ SEARCH_ITEM_HEIGHT,
10
+ SEPARATOR_ITEM_HEIGHT,
11
+ } from "./constants.js";
12
+
13
+ import SeparatorItem from "../components/separator-item.js";
14
+ import SearchTextInput from "../components/search-text-input.js";
15
+
16
+ import type {DropdownItem} from "./types.js";
17
+
18
+ /**
19
+ * The list height that is automatically calculated depending on the
20
+ * component's type of each item (e.g. Separator, Option, Search, etc)
21
+ *
22
+ * @param {Array<DropdownItem>} items - The list of items to calculate the height
23
+ * @param {number} initialHeight - The initial height of the list
24
+ *
25
+ * @returns {number} The list height
26
+ */
27
+ export function getDropdownMenuHeight(
28
+ items: Array<DropdownItem>,
29
+ initialHeight: number = 0,
30
+ ): number {
31
+ // calculate using the first 10 items on the array as we want to display
32
+ // this number of elements in the visible area
33
+ return items.slice(0, MAX_VISIBLE_ITEMS).reduce((sum, item) => {
34
+ if (SeparatorItem.isClassOf(item.component)) {
35
+ return sum + SEPARATOR_ITEM_HEIGHT;
36
+ } else if (SearchTextInput.isClassOf(item.component)) {
37
+ // search text input height
38
+ return sum + SEARCH_ITEM_HEIGHT;
39
+ } else {
40
+ return sum + DROPDOWN_ITEM_HEIGHT;
41
+ }
42
+ }, initialHeight);
43
+ }
44
+
45
+ /**
46
+ * Wraps the dynamic styles in an Aphrodite style sheet so we can properly apply
47
+ * the styles to a merged stylesheet (instead of inlining the styles).
48
+ *
49
+ * @param {StyleType} customStyles - The custom styles to apply to the dropdown
50
+ * menu.
51
+ * @returns The Aphrodite stylesheet for the dropdown menu.
52
+ */
53
+ export function generateDropdownMenuStyles(
54
+ minWidth: number,
55
+ maxHeight: number,
56
+ ): StyleType {
57
+ const styles = StyleSheet.create({
58
+ dropdownMenu: {
59
+ minWidth,
60
+ maxHeight,
61
+ },
62
+ });
63
+
64
+ return styles.dropdownMenu;
65
+ }