@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/CHANGELOG.md +30 -0
- package/dist/es/index.js +172 -656
- package/dist/index.js +228 -326
- package/package.json +11 -10
- package/src/components/__tests__/dropdown-core.test.js +7 -3
- package/src/components/__tests__/search-text-input.test.js +124 -57
- package/src/components/__tests__/single-select.test.js +102 -1
- package/src/components/dropdown-core-virtualized.js +11 -34
- package/src/components/dropdown-core.js +17 -1
- package/src/components/search-text-input.js +15 -149
- package/src/components/single-select.stories.js +1 -0
- package/src/util/__tests__/dropdown-menu-styles.test.js +100 -0
- package/src/util/constants.js +7 -1
- package/src/util/dropdown-menu-styles.js +65 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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} =
|
|
151
|
-
|
|
77
|
+
const {labels, onChange, onClick, itemRef, searchText, style, testId} =
|
|
78
|
+
this.props;
|
|
152
79
|
|
|
153
80
|
return (
|
|
154
|
-
<
|
|
81
|
+
<SearchField
|
|
82
|
+
clearAriaLabel={labels.clearSearch}
|
|
83
|
+
onChange={onChange}
|
|
155
84
|
onClick={onClick}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
});
|
package/src/util/constants.js
CHANGED
|
@@ -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
|
+
}
|