@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/dist/es/index.js +3403 -0
  3. package/dist/index.js +3966 -0
  4. package/dist/index.js.flow +2 -0
  5. package/docs.md +12 -0
  6. package/package.json +44 -0
  7. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +4054 -0
  8. package/src/__tests__/generated-snapshot.test.js +1612 -0
  9. package/src/__tests__/index.test.js +23 -0
  10. package/src/components/__mocks__/dropdown-core-virtualized.js +40 -0
  11. package/src/components/__tests__/__snapshots__/action-item.test.js.snap +63 -0
  12. package/src/components/__tests__/action-item.test.js +43 -0
  13. package/src/components/__tests__/action-menu.test.js +544 -0
  14. package/src/components/__tests__/dropdown-core-virtualized.test.js +119 -0
  15. package/src/components/__tests__/dropdown-core.test.js +659 -0
  16. package/src/components/__tests__/multi-select.test.js +982 -0
  17. package/src/components/__tests__/search-text-input.test.js +144 -0
  18. package/src/components/__tests__/single-select.test.js +588 -0
  19. package/src/components/action-item.js +270 -0
  20. package/src/components/action-menu-opener-core.js +203 -0
  21. package/src/components/action-menu.js +300 -0
  22. package/src/components/action-menu.md +338 -0
  23. package/src/components/check.js +59 -0
  24. package/src/components/checkbox.js +111 -0
  25. package/src/components/dropdown-core-virtualized-item.js +62 -0
  26. package/src/components/dropdown-core-virtualized.js +246 -0
  27. package/src/components/dropdown-core.js +770 -0
  28. package/src/components/dropdown-opener.js +101 -0
  29. package/src/components/multi-select.js +597 -0
  30. package/src/components/multi-select.md +718 -0
  31. package/src/components/multi-select.stories.js +111 -0
  32. package/src/components/option-item.js +239 -0
  33. package/src/components/search-text-input.js +227 -0
  34. package/src/components/select-opener.js +297 -0
  35. package/src/components/separator-item.js +50 -0
  36. package/src/components/single-select.js +418 -0
  37. package/src/components/single-select.md +520 -0
  38. package/src/components/single-select.stories.js +107 -0
  39. package/src/index.js +20 -0
  40. package/src/util/constants.js +50 -0
  41. 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
+ }