@khanacademy/wonder-blocks-dropdown 2.3.19
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/LICENSE +21 -0
- package/dist/es/index.js +3403 -0
- package/dist/index.js +3966 -0
- package/dist/index.js.flow +2 -0
- package/docs.md +12 -0
- package/package.json +44 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +4054 -0
- package/src/__tests__/generated-snapshot.test.js +1612 -0
- package/src/__tests__/index.test.js +23 -0
- package/src/components/__mocks__/dropdown-core-virtualized.js +40 -0
- package/src/components/__tests__/__snapshots__/action-item.test.js.snap +63 -0
- package/src/components/__tests__/action-item.test.js +43 -0
- package/src/components/__tests__/action-menu.test.js +544 -0
- package/src/components/__tests__/dropdown-core-virtualized.test.js +119 -0
- package/src/components/__tests__/dropdown-core.test.js +659 -0
- package/src/components/__tests__/multi-select.test.js +982 -0
- package/src/components/__tests__/search-text-input.test.js +144 -0
- package/src/components/__tests__/single-select.test.js +588 -0
- package/src/components/action-item.js +270 -0
- package/src/components/action-menu-opener-core.js +203 -0
- package/src/components/action-menu.js +300 -0
- package/src/components/action-menu.md +338 -0
- package/src/components/check.js +59 -0
- package/src/components/checkbox.js +111 -0
- package/src/components/dropdown-core-virtualized-item.js +62 -0
- package/src/components/dropdown-core-virtualized.js +246 -0
- package/src/components/dropdown-core.js +770 -0
- package/src/components/dropdown-opener.js +101 -0
- package/src/components/multi-select.js +597 -0
- package/src/components/multi-select.md +718 -0
- package/src/components/multi-select.stories.js +111 -0
- package/src/components/option-item.js +239 -0
- package/src/components/search-text-input.js +227 -0
- package/src/components/select-opener.js +297 -0
- package/src/components/separator-item.js +50 -0
- package/src/components/single-select.js +418 -0
- package/src/components/single-select.md +520 -0
- package/src/components/single-select.stories.js +107 -0
- package/src/index.js +20 -0
- package/src/util/constants.js +50 -0
- package/src/util/types.js +32 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {StyleSheet} from "aphrodite";
|
|
5
|
+
import * as PropTypes from "prop-types";
|
|
6
|
+
|
|
7
|
+
import type {AriaProps} from "@khanacademy/wonder-blocks-core";
|
|
8
|
+
|
|
9
|
+
import Color, {mix, fade} from "@khanacademy/wonder-blocks-color";
|
|
10
|
+
import {addStyle} from "@khanacademy/wonder-blocks-core";
|
|
11
|
+
import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
|
|
12
|
+
import Icon, {icons} from "@khanacademy/wonder-blocks-icon";
|
|
13
|
+
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
14
|
+
import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
|
|
15
|
+
import {DROPDOWN_ITEM_HEIGHT} from "../util/constants.js";
|
|
16
|
+
|
|
17
|
+
const StyledButton = addStyle("button");
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
blue,
|
|
21
|
+
white,
|
|
22
|
+
white50,
|
|
23
|
+
offBlack,
|
|
24
|
+
offBlack16,
|
|
25
|
+
offBlack32,
|
|
26
|
+
offBlack64,
|
|
27
|
+
} = Color;
|
|
28
|
+
|
|
29
|
+
type SelectOpenerProps = {|
|
|
30
|
+
...AriaProps,
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Display text in the SelectOpener.
|
|
34
|
+
*/
|
|
35
|
+
children: string,
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the SelectOpener is disabled. If disabled, disallows interaction.
|
|
39
|
+
* Default false.
|
|
40
|
+
*/
|
|
41
|
+
disabled: boolean,
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Auto-populated by parent. Used for accessibility purposes, where the label
|
|
45
|
+
* id should match the field id.
|
|
46
|
+
*/
|
|
47
|
+
id?: string,
|
|
48
|
+
|
|
49
|
+
//TODO: error state
|
|
50
|
+
// error: boolean,
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the displayed text is a placeholder, determined by the creator
|
|
54
|
+
* of this component. A placeholder has more faded text colors and styles.
|
|
55
|
+
*/
|
|
56
|
+
isPlaceholder: boolean,
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether to display the "light" version of this component instead, for
|
|
60
|
+
* use when the item is used on a dark background.
|
|
61
|
+
*/
|
|
62
|
+
light: boolean,
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Test ID used for e2e testing.
|
|
66
|
+
*/
|
|
67
|
+
testId?: string,
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Callback for when the SelectOpener is pressed.
|
|
71
|
+
*/
|
|
72
|
+
onOpenChanged: (open: boolean) => mixed,
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether the dropdown is open.
|
|
76
|
+
*/
|
|
77
|
+
open: boolean,
|
|
78
|
+
|};
|
|
79
|
+
|
|
80
|
+
type ContextTypes = {|
|
|
81
|
+
router: $FlowFixMe,
|
|
82
|
+
|};
|
|
83
|
+
|
|
84
|
+
type DefaultProps = {|
|
|
85
|
+
disabled: $PropertyType<SelectOpenerProps, "disabled">,
|
|
86
|
+
light: $PropertyType<SelectOpenerProps, "light">,
|
|
87
|
+
isPlaceholder: $PropertyType<SelectOpenerProps, "isPlaceholder">,
|
|
88
|
+
|};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An opener that opens select boxes.
|
|
92
|
+
*/
|
|
93
|
+
export default class SelectOpener extends React.Component<SelectOpenerProps> {
|
|
94
|
+
static contextTypes: ContextTypes = {router: PropTypes.any};
|
|
95
|
+
static defaultProps: DefaultProps = {
|
|
96
|
+
disabled: false,
|
|
97
|
+
light: false,
|
|
98
|
+
isPlaceholder: false,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
handleClick: (e: SyntheticEvent<>) => void = (e) => {
|
|
102
|
+
const {open} = this.props;
|
|
103
|
+
this.props.onOpenChanged(!open);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
render(): React.Node {
|
|
107
|
+
const {
|
|
108
|
+
children,
|
|
109
|
+
disabled,
|
|
110
|
+
id,
|
|
111
|
+
isPlaceholder,
|
|
112
|
+
light,
|
|
113
|
+
open,
|
|
114
|
+
testId,
|
|
115
|
+
// eslint-disable-next-line no-unused-vars
|
|
116
|
+
onOpenChanged,
|
|
117
|
+
...sharedProps
|
|
118
|
+
} = this.props;
|
|
119
|
+
|
|
120
|
+
const ClickableBehavior = getClickableBehavior(this.context.router);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<ClickableBehavior disabled={disabled} onClick={this.handleClick}>
|
|
124
|
+
{(state, childrenProps) => {
|
|
125
|
+
const stateStyles = _generateStyles(light, isPlaceholder);
|
|
126
|
+
const {hovered, focused, pressed} = state;
|
|
127
|
+
|
|
128
|
+
// The icon colors are kind of fickle. This is just logic
|
|
129
|
+
// based on the zeplin design.
|
|
130
|
+
const iconColor = light
|
|
131
|
+
? disabled || pressed
|
|
132
|
+
? "currentColor"
|
|
133
|
+
: white
|
|
134
|
+
: disabled
|
|
135
|
+
? offBlack32
|
|
136
|
+
: offBlack64;
|
|
137
|
+
|
|
138
|
+
const style = [
|
|
139
|
+
styles.shared,
|
|
140
|
+
stateStyles.default,
|
|
141
|
+
disabled && stateStyles.disabled,
|
|
142
|
+
!disabled &&
|
|
143
|
+
(pressed
|
|
144
|
+
? stateStyles.active
|
|
145
|
+
: (hovered || focused) && stateStyles.focus),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<StyledButton
|
|
150
|
+
{...sharedProps}
|
|
151
|
+
aria-expanded={open ? "true" : "false"}
|
|
152
|
+
aria-haspopup="listbox"
|
|
153
|
+
data-test-id={testId}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
id={id}
|
|
156
|
+
style={style}
|
|
157
|
+
type="button"
|
|
158
|
+
{...childrenProps}
|
|
159
|
+
>
|
|
160
|
+
<LabelMedium style={styles.text}>
|
|
161
|
+
{children}
|
|
162
|
+
</LabelMedium>
|
|
163
|
+
<Icon
|
|
164
|
+
icon={icons.caretDown}
|
|
165
|
+
color={iconColor}
|
|
166
|
+
size="small"
|
|
167
|
+
style={styles.caret}
|
|
168
|
+
aria-hidden="true"
|
|
169
|
+
/>
|
|
170
|
+
</StyledButton>
|
|
171
|
+
);
|
|
172
|
+
}}
|
|
173
|
+
</ClickableBehavior>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const buttonRadius = 4;
|
|
179
|
+
|
|
180
|
+
const styles = StyleSheet.create({
|
|
181
|
+
// TODO: Dedupe with Button styles
|
|
182
|
+
shared: {
|
|
183
|
+
position: "relative",
|
|
184
|
+
display: "inline-flex",
|
|
185
|
+
alignItems: "center",
|
|
186
|
+
justifyContent: "space-between",
|
|
187
|
+
color: offBlack,
|
|
188
|
+
height: DROPDOWN_ITEM_HEIGHT,
|
|
189
|
+
// This asymmetry arises from the Icon on the right side, which has
|
|
190
|
+
// extra padding built in. To have the component look more balanced,
|
|
191
|
+
// we need to take off some paddingRight here.
|
|
192
|
+
paddingLeft: 16,
|
|
193
|
+
paddingRight: 12,
|
|
194
|
+
borderWidth: 0,
|
|
195
|
+
borderRadius: buttonRadius,
|
|
196
|
+
borderStyle: "solid",
|
|
197
|
+
outline: "none",
|
|
198
|
+
textDecoration: "none",
|
|
199
|
+
boxSizing: "border-box",
|
|
200
|
+
whiteSpace: "nowrap",
|
|
201
|
+
// This removes the 300ms click delay on mobile browsers by indicating that
|
|
202
|
+
// "double-tap to zoom" shouldn't be used on this element.
|
|
203
|
+
touchAction: "manipulation",
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
text: {
|
|
207
|
+
marginRight: Spacing.xSmall_8,
|
|
208
|
+
whiteSpace: "nowrap",
|
|
209
|
+
userSelect: "none",
|
|
210
|
+
overflow: "hidden",
|
|
211
|
+
textOverflow: "ellipsis",
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
caret: {
|
|
215
|
+
minWidth: 16,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// These values are default padding (16 and 12) minus 1, because
|
|
220
|
+
// changing the borderWidth to 2 messes up the button width
|
|
221
|
+
// and causes it to move a couple pixels. This fixes that.
|
|
222
|
+
const adjustedPaddingLeft = 16 - 1;
|
|
223
|
+
const adjustedPaddingRight = 12 - 1;
|
|
224
|
+
|
|
225
|
+
const stateStyles = {};
|
|
226
|
+
|
|
227
|
+
const _generateStyles = (light, placeholder) => {
|
|
228
|
+
// "hash" the parameters
|
|
229
|
+
const styleKey = `${String(light)}-${String(placeholder)}`;
|
|
230
|
+
if (stateStyles[styleKey]) {
|
|
231
|
+
return stateStyles[styleKey];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let newStyles = {};
|
|
235
|
+
if (light) {
|
|
236
|
+
newStyles = {
|
|
237
|
+
default: {
|
|
238
|
+
backgroundColor: "transparent",
|
|
239
|
+
color: placeholder ? white50 : white,
|
|
240
|
+
borderColor: white50,
|
|
241
|
+
borderWidth: 1,
|
|
242
|
+
},
|
|
243
|
+
focus: {
|
|
244
|
+
borderColor: white,
|
|
245
|
+
borderWidth: 2,
|
|
246
|
+
paddingLeft: adjustedPaddingLeft,
|
|
247
|
+
paddingRight: adjustedPaddingRight,
|
|
248
|
+
},
|
|
249
|
+
active: {
|
|
250
|
+
paddingLeft: adjustedPaddingLeft,
|
|
251
|
+
paddingRight: adjustedPaddingRight,
|
|
252
|
+
borderColor: mix(fade(blue, 0.32), white),
|
|
253
|
+
borderWidth: 2,
|
|
254
|
+
color: placeholder
|
|
255
|
+
? mix(fade(white, 0.32), blue)
|
|
256
|
+
: mix(fade(blue, 0.32), white),
|
|
257
|
+
backgroundColor: mix(offBlack32, blue),
|
|
258
|
+
},
|
|
259
|
+
disabled: {
|
|
260
|
+
borderColor: mix(fade(white, 0.32), blue),
|
|
261
|
+
color: mix(fade(white, 0.32), blue),
|
|
262
|
+
cursor: "auto",
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
} else {
|
|
266
|
+
newStyles = {
|
|
267
|
+
default: {
|
|
268
|
+
backgroundColor: white,
|
|
269
|
+
borderColor: offBlack16,
|
|
270
|
+
borderWidth: 1,
|
|
271
|
+
color: placeholder ? offBlack64 : offBlack,
|
|
272
|
+
},
|
|
273
|
+
focus: {
|
|
274
|
+
borderColor: blue,
|
|
275
|
+
borderWidth: 2,
|
|
276
|
+
paddingLeft: adjustedPaddingLeft,
|
|
277
|
+
paddingRight: adjustedPaddingRight,
|
|
278
|
+
},
|
|
279
|
+
active: {
|
|
280
|
+
background: mix(fade(blue, 0.32), white),
|
|
281
|
+
borderColor: mix(offBlack32, blue),
|
|
282
|
+
borderWidth: 2,
|
|
283
|
+
paddingLeft: adjustedPaddingLeft,
|
|
284
|
+
paddingRight: adjustedPaddingRight,
|
|
285
|
+
},
|
|
286
|
+
disabled: {
|
|
287
|
+
backgroundColor: "transparent",
|
|
288
|
+
borderColor: offBlack16,
|
|
289
|
+
color: offBlack64,
|
|
290
|
+
cursor: "auto",
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
stateStyles[styleKey] = StyleSheet.create(newStyles);
|
|
296
|
+
return stateStyles[styleKey];
|
|
297
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
// Separator item in a dropdown, used to denote a semantic break.
|
|
3
|
+
// Actualized as a horizontal line with surrounding whitespace. -----
|
|
4
|
+
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import {StyleSheet} from "aphrodite";
|
|
7
|
+
|
|
8
|
+
import Color from "@khanacademy/wonder-blocks-color";
|
|
9
|
+
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
10
|
+
import {View} from "@khanacademy/wonder-blocks-core";
|
|
11
|
+
|
|
12
|
+
import type {StyleType} from "@khanacademy/wonder-blocks-core";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A separator used in a dropdown menu.
|
|
16
|
+
*/
|
|
17
|
+
export default class SeparatorItem extends React.Component<{|
|
|
18
|
+
/**
|
|
19
|
+
* In case we use react-window, this needs to be added in order to inject
|
|
20
|
+
* styles to calculate the position
|
|
21
|
+
* @ignore
|
|
22
|
+
*/
|
|
23
|
+
style?: StyleType,
|
|
24
|
+
|}> {
|
|
25
|
+
static isClassOf(instance: React.Element<any>): boolean {
|
|
26
|
+
return instance && instance.type && instance.type.__IS_SEPARATOR_ITEM__;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static __IS_SEPARATOR_ITEM__: boolean = true;
|
|
30
|
+
|
|
31
|
+
render(): React.Node {
|
|
32
|
+
return (
|
|
33
|
+
// pass optional styles from react-window (if applies)
|
|
34
|
+
<View
|
|
35
|
+
style={[styles.separator, this.props.style]}
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
separator: {
|
|
44
|
+
boxShadow: `0 -1px ${Color.offBlack16}`,
|
|
45
|
+
height: 1,
|
|
46
|
+
minHeight: 1,
|
|
47
|
+
marginTop: Spacing.xxxSmall_4,
|
|
48
|
+
marginBottom: Spacing.xxxSmall_4,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import ReactDOM from "react-dom";
|
|
5
|
+
|
|
6
|
+
import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
|
|
7
|
+
|
|
8
|
+
import DropdownCore from "./dropdown-core.js";
|
|
9
|
+
import DropdownOpener from "./dropdown-opener.js";
|
|
10
|
+
import SelectOpener from "./select-opener.js";
|
|
11
|
+
import {
|
|
12
|
+
defaultLabels,
|
|
13
|
+
selectDropdownStyle,
|
|
14
|
+
filterableDropdownStyle,
|
|
15
|
+
} from "../util/constants.js";
|
|
16
|
+
|
|
17
|
+
import typeof OptionItem from "./option-item.js";
|
|
18
|
+
import type {DropdownItem, OpenerProps} from "../util/types.js";
|
|
19
|
+
import SearchTextInput from "./search-text-input.js";
|
|
20
|
+
|
|
21
|
+
type Props = {|
|
|
22
|
+
...AriaProps,
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The items in this select.
|
|
26
|
+
*/
|
|
27
|
+
children?: Array<?(React.Element<OptionItem> | false)>,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Callback for when the selection. Parameter is the value of the newly
|
|
31
|
+
* selected item.
|
|
32
|
+
*/
|
|
33
|
+
onChange: (selectedValue: string) => mixed,
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Can be used to override the state of the ActionMenu by parent elements
|
|
37
|
+
*/
|
|
38
|
+
opened?: boolean,
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In controlled mode, use this prop in case the parent needs to be notified
|
|
42
|
+
* when the menu opens/closes.
|
|
43
|
+
*/
|
|
44
|
+
onToggle?: (opened: boolean) => mixed,
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Unique identifier attached to the field control. If used, we need to
|
|
48
|
+
* guarantee that the ID is unique within everything rendered on a page.
|
|
49
|
+
* Used to match `<label>` with `<button>` elements for screenreaders.
|
|
50
|
+
*/
|
|
51
|
+
id?: string,
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Placeholder for the opening component when there are no items selected.
|
|
55
|
+
*/
|
|
56
|
+
placeholder: string,
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Value of the currently selected item.
|
|
60
|
+
*/
|
|
61
|
+
selectedValue?: ?string,
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether this dropdown should be left-aligned or right-aligned with the
|
|
65
|
+
* opener component. Defaults to left-aligned.
|
|
66
|
+
*/
|
|
67
|
+
alignment: "left" | "right",
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether this component is disabled. A disabled dropdown may not be opened
|
|
71
|
+
* and does not support interaction. Defaults to false.
|
|
72
|
+
*/
|
|
73
|
+
disabled: boolean,
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether to display the "light" version of this component instead, for
|
|
77
|
+
* use when the component is used on a dark background.
|
|
78
|
+
*/
|
|
79
|
+
light: boolean,
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Optional styling to add to the opener component wrapper.
|
|
83
|
+
*/
|
|
84
|
+
style?: StyleType,
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Adds CSS classes to the opener component wrapper.
|
|
88
|
+
*/
|
|
89
|
+
className?: string,
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Test ID used for e2e testing.
|
|
93
|
+
*/
|
|
94
|
+
testId?: string,
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Optional styling to add to the dropdown wrapper.
|
|
98
|
+
*/
|
|
99
|
+
dropdownStyle?: StyleType,
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The child function that returns the anchor the ActionMenu will be
|
|
103
|
+
* activated by. This function takes eventState, which allows the opener
|
|
104
|
+
* element to access pointer event state.
|
|
105
|
+
*/
|
|
106
|
+
opener?: (openerProps: OpenerProps) => React.Element<any>,
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* When this is true, the dropdown body shows a search text input at the
|
|
110
|
+
* top. The items will be filtered by the input.
|
|
111
|
+
*/
|
|
112
|
+
isFilterable?: boolean,
|
|
113
|
+
|};
|
|
114
|
+
|
|
115
|
+
type State = {|
|
|
116
|
+
/**
|
|
117
|
+
* Whether or not the dropdown is open.
|
|
118
|
+
*/
|
|
119
|
+
open: boolean,
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The text input to filter the items by their label. Defaults to an empty
|
|
123
|
+
* string.
|
|
124
|
+
*/
|
|
125
|
+
searchText: string,
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The DOM reference to the opener element. This is mainly used to set focus
|
|
129
|
+
* to this element, and also to pass the reference to Popper.js.
|
|
130
|
+
*/
|
|
131
|
+
openerElement: ?HTMLElement,
|
|
132
|
+
|};
|
|
133
|
+
|
|
134
|
+
type DefaultProps = {|
|
|
135
|
+
alignment: $PropertyType<Props, "alignment">,
|
|
136
|
+
disabled: $PropertyType<Props, "disabled">,
|
|
137
|
+
light: $PropertyType<Props, "light">,
|
|
138
|
+
|};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The single select allows the selection of one item. Clients are responsible
|
|
142
|
+
* for keeping track of the selected item in the select.
|
|
143
|
+
*
|
|
144
|
+
* The single select dropdown closes after the selection of an item. If the same
|
|
145
|
+
* item is selected, there is no callback.
|
|
146
|
+
*
|
|
147
|
+
* *NOTE:* The component automatically uses
|
|
148
|
+
* [react-window](https://github.com/bvaughn/react-window) to improve
|
|
149
|
+
* performance when rendering these elements and is capable of handling many
|
|
150
|
+
* hundreds of items without performance problems.
|
|
151
|
+
*
|
|
152
|
+
*/
|
|
153
|
+
export default class SingleSelect extends React.Component<Props, State> {
|
|
154
|
+
selectedIndex: number;
|
|
155
|
+
|
|
156
|
+
static defaultProps: DefaultProps = {
|
|
157
|
+
alignment: "left",
|
|
158
|
+
disabled: false,
|
|
159
|
+
light: false,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
constructor(props: Props) {
|
|
163
|
+
super(props);
|
|
164
|
+
|
|
165
|
+
this.selectedIndex = 0;
|
|
166
|
+
|
|
167
|
+
this.state = {
|
|
168
|
+
open: false,
|
|
169
|
+
searchText: "",
|
|
170
|
+
openerElement: null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Used to sync the `opened` state when this component acts as a controlled
|
|
176
|
+
* component
|
|
177
|
+
*/
|
|
178
|
+
static getDerivedStateFromProps(
|
|
179
|
+
props: Props,
|
|
180
|
+
state: State,
|
|
181
|
+
): ?Partial<State> {
|
|
182
|
+
return {
|
|
183
|
+
open: typeof props.opened === "boolean" ? props.opened : state.open,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
handleOpenChanged: (opened: boolean) => void = (opened) => {
|
|
188
|
+
this.setState({
|
|
189
|
+
open: opened,
|
|
190
|
+
searchText: "",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (this.props.onToggle) {
|
|
194
|
+
this.props.onToggle(opened);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
handleToggle: (selectedValue: string) => void = (selectedValue) => {
|
|
199
|
+
// Call callback if selection changed.
|
|
200
|
+
if (selectedValue !== this.props.selectedValue) {
|
|
201
|
+
this.props.onChange(selectedValue);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Bring focus back to the opener element.
|
|
205
|
+
if (this.state.open && this.state.openerElement) {
|
|
206
|
+
this.state.openerElement.focus();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.setState({
|
|
210
|
+
open: false, // close the menu upon selection
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (this.props.onToggle) {
|
|
214
|
+
this.props.onToggle(false);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
mapOptionItemsToDropdownItems: (
|
|
219
|
+
children: Array<React.Element<OptionItem>>,
|
|
220
|
+
) => Array<DropdownItem> = (children) => {
|
|
221
|
+
// Figure out which index should receive focus when this select opens
|
|
222
|
+
// Needs to exclude counting items that are disabled
|
|
223
|
+
let indexCounter = 0;
|
|
224
|
+
this.selectedIndex = 0;
|
|
225
|
+
|
|
226
|
+
return children.map((option) => {
|
|
227
|
+
const {selectedValue} = this.props;
|
|
228
|
+
const {disabled, value} = option.props;
|
|
229
|
+
const selected = selectedValue === value;
|
|
230
|
+
|
|
231
|
+
if (!disabled) {
|
|
232
|
+
indexCounter += 1;
|
|
233
|
+
}
|
|
234
|
+
if (selected) {
|
|
235
|
+
this.selectedIndex = indexCounter;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
component: option,
|
|
239
|
+
focusable: !disabled,
|
|
240
|
+
populatedProps: {
|
|
241
|
+
onToggle: this.handleToggle,
|
|
242
|
+
selected: selected,
|
|
243
|
+
variant: "check",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
filterChildren(
|
|
250
|
+
children: Array<React.Element<OptionItem>>,
|
|
251
|
+
): Array<React.Element<OptionItem>> {
|
|
252
|
+
const {searchText} = this.state;
|
|
253
|
+
|
|
254
|
+
const lowercasedSearchText = searchText.toLowerCase();
|
|
255
|
+
|
|
256
|
+
// Filter the children with the searchText if any.
|
|
257
|
+
return children.filter(
|
|
258
|
+
({props}) =>
|
|
259
|
+
!searchText ||
|
|
260
|
+
props.label.toLowerCase().indexOf(lowercasedSearchText) > -1,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getMenuItems(
|
|
265
|
+
children: Array<React.Element<OptionItem>>,
|
|
266
|
+
): Array<DropdownItem> {
|
|
267
|
+
const {isFilterable} = this.props;
|
|
268
|
+
|
|
269
|
+
// If it's not filterable, no need to do any extra besides mapping the
|
|
270
|
+
// option items to dropdown items.
|
|
271
|
+
return this.mapOptionItemsToDropdownItems(
|
|
272
|
+
isFilterable ? this.filterChildren(children) : children,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getSearchField(): ?DropdownItem {
|
|
277
|
+
if (!this.props.isFilterable) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
component: (
|
|
283
|
+
<SearchTextInput
|
|
284
|
+
key="search-text-input"
|
|
285
|
+
onChange={this.handleSearchTextChanged}
|
|
286
|
+
searchText={this.state.searchText}
|
|
287
|
+
labels={{
|
|
288
|
+
clearSearch: defaultLabels.clearSearch,
|
|
289
|
+
filter: defaultLabels.filter,
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
),
|
|
293
|
+
focusable: true,
|
|
294
|
+
populatedProps: {},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
handleSearchTextChanged: (searchText: string) => void = (searchText) => {
|
|
299
|
+
this.setState({searchText});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
handleOpenerRef: (node: any) => void = (node) => {
|
|
303
|
+
const openerElement = ((ReactDOM.findDOMNode(node): any): HTMLElement);
|
|
304
|
+
this.setState({openerElement});
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
handleClick: (e: SyntheticEvent<>) => void = (e) => {
|
|
308
|
+
this.handleOpenChanged(!this.state.open);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
renderOpener(
|
|
312
|
+
numItems: number,
|
|
313
|
+
):
|
|
314
|
+
| React.Element<typeof DropdownOpener>
|
|
315
|
+
| React.Element<typeof SelectOpener> {
|
|
316
|
+
const {
|
|
317
|
+
children,
|
|
318
|
+
disabled,
|
|
319
|
+
id,
|
|
320
|
+
light,
|
|
321
|
+
opener,
|
|
322
|
+
placeholder,
|
|
323
|
+
selectedValue,
|
|
324
|
+
testId,
|
|
325
|
+
// the following props are being included here to avoid
|
|
326
|
+
// passing them down to the opener as part of sharedProps
|
|
327
|
+
/* eslint-disable no-unused-vars */
|
|
328
|
+
alignment,
|
|
329
|
+
dropdownStyle,
|
|
330
|
+
isFilterable,
|
|
331
|
+
onChange,
|
|
332
|
+
onToggle,
|
|
333
|
+
opened,
|
|
334
|
+
style,
|
|
335
|
+
className,
|
|
336
|
+
/* eslint-enable no-unused-vars */
|
|
337
|
+
...sharedProps
|
|
338
|
+
} = this.props;
|
|
339
|
+
|
|
340
|
+
const selectedItem = React.Children.toArray(children).find(
|
|
341
|
+
(option) => option.props.value === selectedValue,
|
|
342
|
+
);
|
|
343
|
+
// If nothing is selected, or if the selectedValue doesn't match any
|
|
344
|
+
// item in the menu, use the placeholder.
|
|
345
|
+
const menuText = selectedItem ? selectedItem.props.label : placeholder;
|
|
346
|
+
|
|
347
|
+
const dropdownOpener = opener ? (
|
|
348
|
+
<DropdownOpener
|
|
349
|
+
onClick={this.handleClick}
|
|
350
|
+
disabled={numItems === 0 || disabled}
|
|
351
|
+
ref={this.handleOpenerRef}
|
|
352
|
+
text={menuText}
|
|
353
|
+
>
|
|
354
|
+
{opener}
|
|
355
|
+
</DropdownOpener>
|
|
356
|
+
) : (
|
|
357
|
+
<SelectOpener
|
|
358
|
+
{...sharedProps}
|
|
359
|
+
disabled={numItems === 0 || disabled}
|
|
360
|
+
id={id}
|
|
361
|
+
isPlaceholder={!selectedItem}
|
|
362
|
+
light={light}
|
|
363
|
+
onOpenChanged={this.handleOpenChanged}
|
|
364
|
+
open={this.state.open}
|
|
365
|
+
ref={this.handleOpenerRef}
|
|
366
|
+
testId={testId}
|
|
367
|
+
>
|
|
368
|
+
{menuText}
|
|
369
|
+
</SelectOpener>
|
|
370
|
+
);
|
|
371
|
+
return dropdownOpener;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
render(): React.Node {
|
|
375
|
+
const {
|
|
376
|
+
alignment,
|
|
377
|
+
children,
|
|
378
|
+
dropdownStyle,
|
|
379
|
+
isFilterable,
|
|
380
|
+
light,
|
|
381
|
+
style,
|
|
382
|
+
className,
|
|
383
|
+
} = this.props;
|
|
384
|
+
const {searchText} = this.state;
|
|
385
|
+
const allChildren = React.Children.toArray(children).filter(Boolean);
|
|
386
|
+
const filteredItems = this.getMenuItems(allChildren);
|
|
387
|
+
const opener = this.renderOpener(allChildren.length);
|
|
388
|
+
const searchField = this.getSearchField();
|
|
389
|
+
const items = searchField
|
|
390
|
+
? [searchField, ...filteredItems]
|
|
391
|
+
: filteredItems;
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<DropdownCore
|
|
395
|
+
role="listbox"
|
|
396
|
+
alignment={alignment}
|
|
397
|
+
dropdownStyle={[
|
|
398
|
+
isFilterable && filterableDropdownStyle,
|
|
399
|
+
selectDropdownStyle,
|
|
400
|
+
dropdownStyle,
|
|
401
|
+
]}
|
|
402
|
+
initialFocusedIndex={this.selectedIndex}
|
|
403
|
+
items={items}
|
|
404
|
+
light={light}
|
|
405
|
+
onOpenChanged={this.handleOpenChanged}
|
|
406
|
+
open={this.state.open}
|
|
407
|
+
opener={opener}
|
|
408
|
+
openerElement={this.state.openerElement}
|
|
409
|
+
style={style}
|
|
410
|
+
className={className}
|
|
411
|
+
onSearchTextChanged={
|
|
412
|
+
isFilterable ? this.handleSearchTextChanged : null
|
|
413
|
+
}
|
|
414
|
+
searchText={isFilterable ? searchText : ""}
|
|
415
|
+
/>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|