@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,101 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import {ClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
|
|
5
|
+
|
|
6
|
+
import type {AriaProps} from "@khanacademy/wonder-blocks-core";
|
|
7
|
+
import type {
|
|
8
|
+
ChildrenProps,
|
|
9
|
+
ClickableState,
|
|
10
|
+
} from "@khanacademy/wonder-blocks-clickable";
|
|
11
|
+
|
|
12
|
+
import type {OpenerProps} from "../util/types.js";
|
|
13
|
+
|
|
14
|
+
type Props = {|
|
|
15
|
+
...$Rest<AriaProps, {|"aria-disabled": "true" | "false" | void|}>,
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The child function that returns the anchor the Dropdown will be activated
|
|
19
|
+
* by. This function takes two arguments:
|
|
20
|
+
*
|
|
21
|
+
* - `eventState`: allows the opener element to access pointer event state.
|
|
22
|
+
* - `text`: Passes the menu's text/label defined in the parent component.
|
|
23
|
+
*/
|
|
24
|
+
children: (openerProps: OpenerProps) => React.Element<any>,
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the opener is disabled. If disabled, disallows interaction.
|
|
28
|
+
*/
|
|
29
|
+
disabled: boolean,
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Callback for when the opener is pressed.
|
|
33
|
+
*/
|
|
34
|
+
onClick: (e: SyntheticEvent<>) => mixed,
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Test ID used for e2e testing.
|
|
38
|
+
*/
|
|
39
|
+
testId?: string,
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Text for the opener that can be passed to the child as an argument.
|
|
43
|
+
*/
|
|
44
|
+
text: string,
|
|
45
|
+
|};
|
|
46
|
+
|
|
47
|
+
type DefaultProps = {|
|
|
48
|
+
disabled: $PropertyType<Props, "disabled">,
|
|
49
|
+
|};
|
|
50
|
+
|
|
51
|
+
class DropdownOpener extends React.Component<Props> {
|
|
52
|
+
static defaultProps: DefaultProps = {
|
|
53
|
+
disabled: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
getTestIdFromProps: (childrenProps: any) => string = (childrenProps) => {
|
|
57
|
+
return childrenProps.testId || childrenProps["data-test-id"];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
renderAnchorChildren(
|
|
61
|
+
eventState: ClickableState,
|
|
62
|
+
clickableChildrenProps: ChildrenProps,
|
|
63
|
+
): React.Node {
|
|
64
|
+
const {disabled, testId, text} = this.props;
|
|
65
|
+
const renderedChildren = this.props.children({...eventState, text});
|
|
66
|
+
const childrenProps = renderedChildren.props;
|
|
67
|
+
const childrenTestId = this.getTestIdFromProps(childrenProps);
|
|
68
|
+
|
|
69
|
+
return React.cloneElement(renderedChildren, {
|
|
70
|
+
...clickableChildrenProps,
|
|
71
|
+
disabled,
|
|
72
|
+
onClick: childrenProps.onClick
|
|
73
|
+
? (e: SyntheticMouseEvent<>) => {
|
|
74
|
+
// This is done to avoid overriding a
|
|
75
|
+
// custom onClick handler inside the
|
|
76
|
+
// children node
|
|
77
|
+
childrenProps.onClick(e);
|
|
78
|
+
clickableChildrenProps.onClick(e);
|
|
79
|
+
}
|
|
80
|
+
: clickableChildrenProps.onClick,
|
|
81
|
+
// try to get the testId from the child element
|
|
82
|
+
// If it's not set, try to fallback to the parent's testId
|
|
83
|
+
"data-test-id": childrenTestId || testId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
render(): React.Node {
|
|
88
|
+
return (
|
|
89
|
+
<ClickableBehavior
|
|
90
|
+
onClick={this.props.onClick}
|
|
91
|
+
disabled={this.props.disabled}
|
|
92
|
+
>
|
|
93
|
+
{(eventState, handlers) =>
|
|
94
|
+
this.renderAnchorChildren(eventState, handlers)
|
|
95
|
+
}
|
|
96
|
+
</ClickableBehavior>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default DropdownOpener;
|
|
@@ -0,0 +1,597 @@
|
|
|
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 ActionItem from "./action-item.js";
|
|
9
|
+
import DropdownCore from "./dropdown-core.js";
|
|
10
|
+
import DropdownOpener from "./dropdown-opener.js";
|
|
11
|
+
import SearchTextInput from "./search-text-input.js";
|
|
12
|
+
import SelectOpener from "./select-opener.js";
|
|
13
|
+
import SeparatorItem from "./separator-item.js";
|
|
14
|
+
import {
|
|
15
|
+
defaultLabels,
|
|
16
|
+
selectDropdownStyle,
|
|
17
|
+
filterableDropdownStyle,
|
|
18
|
+
} from "../util/constants.js";
|
|
19
|
+
|
|
20
|
+
import typeof OptionItem from "./option-item.js";
|
|
21
|
+
import type {DropdownItem, OpenerProps} from "../util/types.js";
|
|
22
|
+
|
|
23
|
+
export type Labels = {|
|
|
24
|
+
/**
|
|
25
|
+
* Label for describing the dismiss icon on the search filter.
|
|
26
|
+
*/
|
|
27
|
+
clearSearch: string,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Label for the search placeholder.
|
|
31
|
+
*/
|
|
32
|
+
filter: string,
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Label for when the filter returns no results.
|
|
36
|
+
*/
|
|
37
|
+
noResults: string,
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Label for the "select all" shortcut option.
|
|
41
|
+
*/
|
|
42
|
+
selectAllLabel: (numOptions: number) => string,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Label for the "select none" shortcut option
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
selectNoneLabel: string,
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Label for the opening component when there are no items selected.
|
|
52
|
+
*/
|
|
53
|
+
noneSelected: string,
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Label for the opening component when there are some items selected.
|
|
57
|
+
*/
|
|
58
|
+
someSelected: (numOptions: number) => string,
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Label for the opening component when all the items have been selected.
|
|
62
|
+
*/
|
|
63
|
+
allSelected: string,
|
|
64
|
+
|};
|
|
65
|
+
|
|
66
|
+
type DefaultProps = {|
|
|
67
|
+
/**
|
|
68
|
+
* Whether this dropdown should be left-aligned or right-aligned with the
|
|
69
|
+
* opener component. Defaults to left-aligned.
|
|
70
|
+
*/
|
|
71
|
+
alignment: "left" | "right",
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether this component is disabled. A disabled dropdown may not be opened
|
|
75
|
+
* and does not support interaction. Defaults to false.
|
|
76
|
+
*/
|
|
77
|
+
disabled: boolean,
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether to display the "light" version of this component instead, for
|
|
81
|
+
* use when the component is used on a dark background.
|
|
82
|
+
*/
|
|
83
|
+
light: boolean,
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The values of the items that are currently selected.
|
|
87
|
+
*/
|
|
88
|
+
selectedValues: Array<string>,
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether to display shortcuts for Select All and Select None.
|
|
92
|
+
*/
|
|
93
|
+
shortcuts: boolean,
|
|
94
|
+
|};
|
|
95
|
+
|
|
96
|
+
type Props = {|
|
|
97
|
+
...AriaProps,
|
|
98
|
+
|
|
99
|
+
...DefaultProps,
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The items in this select.
|
|
103
|
+
*/
|
|
104
|
+
children?: Array<?(React.Element<OptionItem> | false)>,
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Optional styling to add to the dropdown wrapper.
|
|
108
|
+
*/
|
|
109
|
+
dropdownStyle?: StyleType,
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Unique identifier attached to the field control. If used, we need to
|
|
113
|
+
* guarantee that the ID is unique within everything rendered on a page.
|
|
114
|
+
* Used to match `<label>` with `<button>` elements for screenreaders.
|
|
115
|
+
*/
|
|
116
|
+
id?: string,
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* When this is true, the menu text shows either "All items" or the value
|
|
120
|
+
* set in `props.labels.allSelected` when no item is selected.
|
|
121
|
+
*/
|
|
122
|
+
implicitAllEnabled?: boolean,
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* When this is true, the dropdown body shows a search text input at the
|
|
126
|
+
* top. The items will be filtered by the input.
|
|
127
|
+
* Selected items will be moved to the top when the dropdown is re-opened.
|
|
128
|
+
*/
|
|
129
|
+
isFilterable?: boolean,
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The object containing the custom labels used inside this component.
|
|
133
|
+
*/
|
|
134
|
+
labels?: Labels,
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Callback for when the selection changes. Parameter is an updated array of
|
|
138
|
+
* the values that are now selected.
|
|
139
|
+
*/
|
|
140
|
+
onChange: (selectedValues: Array<string>) => mixed,
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* In controlled mode, use this prop in case the parent needs to be notified
|
|
144
|
+
* when the menu opens/closes.
|
|
145
|
+
*/
|
|
146
|
+
onToggle?: (opened: boolean) => mixed,
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Can be used to override the state of the ActionMenu by parent elements
|
|
150
|
+
*/
|
|
151
|
+
opened?: boolean,
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The child function that returns the anchor the MultiSelect will be
|
|
155
|
+
* activated by. This function takes eventState, which allows the opener
|
|
156
|
+
* element to access pointer event state.
|
|
157
|
+
*/
|
|
158
|
+
opener?: (openerProps: OpenerProps) => React.Element<any>,
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional styling to add to the opener component wrapper.
|
|
162
|
+
*/
|
|
163
|
+
style?: StyleType,
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Adds CSS classes to the opener component wrapper.
|
|
167
|
+
*/
|
|
168
|
+
className?: string,
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Test ID used for e2e testing.
|
|
172
|
+
*/
|
|
173
|
+
testId?: string,
|
|
174
|
+
|};
|
|
175
|
+
|
|
176
|
+
type State = {|
|
|
177
|
+
/**
|
|
178
|
+
* Whether or not the dropdown is open.
|
|
179
|
+
*/
|
|
180
|
+
open: boolean,
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The text input to filter the items by their label. Defaults to an empty
|
|
184
|
+
* string.
|
|
185
|
+
*/
|
|
186
|
+
searchText: string,
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The selected values that are set when the dropdown is opened. We use
|
|
190
|
+
* this to move the selected items to the top when the dropdown is
|
|
191
|
+
* re-opened.
|
|
192
|
+
*/
|
|
193
|
+
lastSelectedValues: Array<string>,
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The object containing the custom labels used inside this component.
|
|
197
|
+
*/
|
|
198
|
+
labels: Labels,
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The DOM reference to the opener element. This is mainly used to set focus
|
|
202
|
+
* to this element, and also to pass the reference to Popper.js.
|
|
203
|
+
*/
|
|
204
|
+
openerElement: ?HTMLElement,
|
|
205
|
+
|};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A dropdown that consists of multiple selection items. This select allows
|
|
209
|
+
* multiple options to be selected. Clients are responsible for keeping track
|
|
210
|
+
* of the selected items.
|
|
211
|
+
*
|
|
212
|
+
* The multi select stays open until closed by the user. The onChange callback
|
|
213
|
+
* happens every time there is a change in the selection of the items.
|
|
214
|
+
*/
|
|
215
|
+
export default class MultiSelect extends React.Component<Props, State> {
|
|
216
|
+
labels: Labels;
|
|
217
|
+
|
|
218
|
+
static defaultProps: DefaultProps = {
|
|
219
|
+
alignment: "left",
|
|
220
|
+
disabled: false,
|
|
221
|
+
light: false,
|
|
222
|
+
shortcuts: false,
|
|
223
|
+
selectedValues: [],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
constructor(props: Props) {
|
|
227
|
+
super(props);
|
|
228
|
+
|
|
229
|
+
this.state = {
|
|
230
|
+
open: false,
|
|
231
|
+
searchText: "",
|
|
232
|
+
lastSelectedValues: [],
|
|
233
|
+
// merge custom labels with the default ones
|
|
234
|
+
labels: {...defaultLabels, ...props.labels},
|
|
235
|
+
openerElement: null,
|
|
236
|
+
};
|
|
237
|
+
// merge custom labels with the default ones
|
|
238
|
+
this.labels = {...defaultLabels, ...props.labels};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Used to sync the `opened` state when this component acts as a controlled
|
|
243
|
+
* component
|
|
244
|
+
*/
|
|
245
|
+
static getDerivedStateFromProps(
|
|
246
|
+
props: Props,
|
|
247
|
+
state: State,
|
|
248
|
+
): ?Partial<State> {
|
|
249
|
+
return {
|
|
250
|
+
open: typeof props.opened === "boolean" ? props.opened : state.open,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
componentDidUpdate(prevProps: Props) {
|
|
255
|
+
if (this.props.labels !== prevProps.labels) {
|
|
256
|
+
// eslint-disable-next-line react/no-did-update-set-state
|
|
257
|
+
this.setState({
|
|
258
|
+
labels: {...this.state.labels, ...this.props.labels},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
handleOpenChanged: (opened: boolean) => void = (opened) => {
|
|
264
|
+
this.setState({
|
|
265
|
+
open: opened,
|
|
266
|
+
searchText: "",
|
|
267
|
+
lastSelectedValues: this.props.selectedValues,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (this.props.onToggle) {
|
|
271
|
+
this.props.onToggle(opened);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
handleToggle: (selectedValue: string) => void = (selectedValue) => {
|
|
276
|
+
const {onChange, selectedValues} = this.props;
|
|
277
|
+
|
|
278
|
+
if (selectedValues.includes(selectedValue)) {
|
|
279
|
+
const index = selectedValues.indexOf(selectedValue);
|
|
280
|
+
const updatedSelection = [
|
|
281
|
+
...selectedValues.slice(0, index),
|
|
282
|
+
...selectedValues.slice(index + 1),
|
|
283
|
+
];
|
|
284
|
+
onChange(updatedSelection);
|
|
285
|
+
} else {
|
|
286
|
+
// Item was newly selected
|
|
287
|
+
onChange([...selectedValues, selectedValue]);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
handleSelectAll: () => void = () => {
|
|
292
|
+
const {children, onChange} = this.props;
|
|
293
|
+
const selected = React.Children.toArray(children)
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
.map((option) => option.props.value);
|
|
296
|
+
onChange(selected);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
handleSelectNone: () => void = () => {
|
|
300
|
+
const {onChange} = this.props;
|
|
301
|
+
onChange([]);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
getMenuText(children: Array<React.Element<OptionItem>>): string {
|
|
305
|
+
const {implicitAllEnabled, selectedValues} = this.props;
|
|
306
|
+
const {noneSelected, someSelected, allSelected} = this.state.labels;
|
|
307
|
+
|
|
308
|
+
// When implicit all enabled, use `labels.allSelected` when no selection
|
|
309
|
+
// otherwise, use the `labels.noneSelected` value
|
|
310
|
+
const noSelectionText = implicitAllEnabled ? allSelected : noneSelected;
|
|
311
|
+
|
|
312
|
+
switch (selectedValues.length) {
|
|
313
|
+
case 0:
|
|
314
|
+
return noSelectionText;
|
|
315
|
+
case 1:
|
|
316
|
+
// If there is one item selected, we display its label. If for
|
|
317
|
+
// some reason we can't find the selected item, we use the
|
|
318
|
+
// display text for the case where nothing is selected.
|
|
319
|
+
const selectedItem = children.find(
|
|
320
|
+
(option) => option.props.value === selectedValues[0],
|
|
321
|
+
);
|
|
322
|
+
return selectedItem
|
|
323
|
+
? selectedItem.props.label
|
|
324
|
+
: noSelectionText;
|
|
325
|
+
case children.length:
|
|
326
|
+
return allSelected;
|
|
327
|
+
default:
|
|
328
|
+
return someSelected(selectedValues.length);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
getSearchField(): Array<DropdownItem> {
|
|
333
|
+
if (!this.props.isFilterable) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const {clearSearch, filter} = this.state.labels;
|
|
338
|
+
|
|
339
|
+
return [
|
|
340
|
+
{
|
|
341
|
+
component: (
|
|
342
|
+
<SearchTextInput
|
|
343
|
+
key="search-text-input"
|
|
344
|
+
onChange={this.handleSearchTextChanged}
|
|
345
|
+
searchText={this.state.searchText}
|
|
346
|
+
labels={{
|
|
347
|
+
clearSearch,
|
|
348
|
+
filter,
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
351
|
+
),
|
|
352
|
+
focusable: true,
|
|
353
|
+
populatedProps: {},
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
getShortcuts(numOptions: number): Array<DropdownItem> {
|
|
359
|
+
const {selectedValues, shortcuts} = this.props;
|
|
360
|
+
const {selectAllLabel, selectNoneLabel} = this.state.labels;
|
|
361
|
+
|
|
362
|
+
// When there's search text input to filter, shortcuts should be hidden
|
|
363
|
+
if (shortcuts && !this.state.searchText) {
|
|
364
|
+
const selectAllDisabled = numOptions === selectedValues.length;
|
|
365
|
+
const selectAll = {
|
|
366
|
+
component: (
|
|
367
|
+
<ActionItem
|
|
368
|
+
disabled={selectAllDisabled}
|
|
369
|
+
label={selectAllLabel(numOptions)}
|
|
370
|
+
indent={true}
|
|
371
|
+
onClick={this.handleSelectAll}
|
|
372
|
+
/>
|
|
373
|
+
),
|
|
374
|
+
focusable: !selectAllDisabled,
|
|
375
|
+
populatedProps: {},
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const selectNoneDisabled = selectedValues.length === 0;
|
|
379
|
+
const selectNone = {
|
|
380
|
+
component: (
|
|
381
|
+
<ActionItem
|
|
382
|
+
disabled={selectNoneDisabled}
|
|
383
|
+
label={selectNoneLabel}
|
|
384
|
+
indent={true}
|
|
385
|
+
onClick={this.handleSelectNone}
|
|
386
|
+
/>
|
|
387
|
+
),
|
|
388
|
+
focusable: !selectNoneDisabled,
|
|
389
|
+
populatedProps: {},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const separator = {
|
|
393
|
+
component: <SeparatorItem key="shortcuts-separator" />,
|
|
394
|
+
focusable: false,
|
|
395
|
+
populatedProps: {},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
return [selectAll, selectNone, separator];
|
|
399
|
+
} else {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
getMenuItems(
|
|
405
|
+
children: Array<React.Element<OptionItem>>,
|
|
406
|
+
): Array<DropdownItem> {
|
|
407
|
+
const {isFilterable} = this.props;
|
|
408
|
+
// If it's not filterable, no need to do any extra besides mapping the
|
|
409
|
+
// option items to dropdown items.
|
|
410
|
+
if (!isFilterable) {
|
|
411
|
+
return children.map(this.mapOptionItemToDropdownItem);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const {searchText, lastSelectedValues} = this.state;
|
|
415
|
+
|
|
416
|
+
const lowercasedSearchText = searchText.toLowerCase();
|
|
417
|
+
|
|
418
|
+
// Filter the children with the searchText if any.
|
|
419
|
+
const filteredChildren = children.filter(
|
|
420
|
+
({props}) =>
|
|
421
|
+
!searchText ||
|
|
422
|
+
props.label.toLowerCase().indexOf(lowercasedSearchText) > -1,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const lastSelectedChildren = [];
|
|
426
|
+
const restOfTheChildren = [];
|
|
427
|
+
for (const child of filteredChildren) {
|
|
428
|
+
if (lastSelectedValues.includes(child.props.value)) {
|
|
429
|
+
lastSelectedChildren.push(child);
|
|
430
|
+
} else {
|
|
431
|
+
restOfTheChildren.push(child);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const lastSelectedItems = lastSelectedChildren.map(
|
|
436
|
+
this.mapOptionItemToDropdownItem,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// We want to add SeparatorItem in between last selected items and the
|
|
440
|
+
// rest of the items only when both of them exists.
|
|
441
|
+
if (lastSelectedChildren.length && restOfTheChildren.length) {
|
|
442
|
+
lastSelectedItems.push({
|
|
443
|
+
component: <SeparatorItem key="selected-separator" />,
|
|
444
|
+
focusable: false,
|
|
445
|
+
populatedProps: {},
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return [
|
|
450
|
+
...lastSelectedItems,
|
|
451
|
+
...restOfTheChildren.map(this.mapOptionItemToDropdownItem),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
mapOptionItemToDropdownItem: (
|
|
456
|
+
option: React.Element<OptionItem>,
|
|
457
|
+
) => DropdownItem = (option: React.Element<OptionItem>): DropdownItem => {
|
|
458
|
+
const {selectedValues} = this.props;
|
|
459
|
+
const {disabled, value} = option.props;
|
|
460
|
+
return {
|
|
461
|
+
component: option,
|
|
462
|
+
focusable: !disabled,
|
|
463
|
+
populatedProps: {
|
|
464
|
+
onToggle: this.handleToggle,
|
|
465
|
+
selected: selectedValues.includes(value),
|
|
466
|
+
variant: "checkbox",
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
handleOpenerRef: (node: any) => void = (node: any) => {
|
|
472
|
+
const openerElement = ((ReactDOM.findDOMNode(node): any): HTMLElement);
|
|
473
|
+
this.setState({openerElement});
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
handleSearchTextChanged: (searchText: string) => void = (
|
|
477
|
+
searchText: string,
|
|
478
|
+
) => {
|
|
479
|
+
this.setState({searchText});
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
handleClick: (e: SyntheticEvent<>) => void = (e: SyntheticEvent<>) => {
|
|
483
|
+
this.handleOpenChanged(!this.state.open);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
renderOpener(
|
|
487
|
+
allChildren: Array<React.Element<OptionItem>>,
|
|
488
|
+
):
|
|
489
|
+
| React.Element<typeof DropdownOpener>
|
|
490
|
+
| React.Element<typeof SelectOpener> {
|
|
491
|
+
const {
|
|
492
|
+
disabled,
|
|
493
|
+
id,
|
|
494
|
+
light,
|
|
495
|
+
opener,
|
|
496
|
+
testId,
|
|
497
|
+
// the following props are being included here to avoid
|
|
498
|
+
// passing them down to the opener as part of sharedProps
|
|
499
|
+
/* eslint-disable no-unused-vars */
|
|
500
|
+
alignment,
|
|
501
|
+
dropdownStyle,
|
|
502
|
+
implicitAllEnabled,
|
|
503
|
+
isFilterable,
|
|
504
|
+
labels,
|
|
505
|
+
onChange,
|
|
506
|
+
onToggle,
|
|
507
|
+
opened,
|
|
508
|
+
selectedValues,
|
|
509
|
+
shortcuts,
|
|
510
|
+
style,
|
|
511
|
+
className,
|
|
512
|
+
/* eslint-enable no-unused-vars */
|
|
513
|
+
...sharedProps
|
|
514
|
+
} = this.props;
|
|
515
|
+
const {noneSelected} = this.state.labels;
|
|
516
|
+
|
|
517
|
+
const menuText = this.getMenuText(allChildren);
|
|
518
|
+
const numOptions = allChildren.length;
|
|
519
|
+
|
|
520
|
+
const dropdownOpener = opener ? (
|
|
521
|
+
<DropdownOpener
|
|
522
|
+
onClick={this.handleClick}
|
|
523
|
+
disabled={numOptions === 0 || disabled}
|
|
524
|
+
ref={this.handleOpenerRef}
|
|
525
|
+
text={menuText}
|
|
526
|
+
>
|
|
527
|
+
{opener}
|
|
528
|
+
</DropdownOpener>
|
|
529
|
+
) : (
|
|
530
|
+
<SelectOpener
|
|
531
|
+
{...sharedProps}
|
|
532
|
+
disabled={numOptions === 0 || disabled}
|
|
533
|
+
id={id}
|
|
534
|
+
isPlaceholder={menuText === noneSelected}
|
|
535
|
+
light={light}
|
|
536
|
+
onOpenChanged={this.handleOpenChanged}
|
|
537
|
+
open={this.state.open}
|
|
538
|
+
ref={this.handleOpenerRef}
|
|
539
|
+
testId={testId}
|
|
540
|
+
>
|
|
541
|
+
{menuText}
|
|
542
|
+
</SelectOpener>
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
return dropdownOpener;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
render(): React.Node {
|
|
549
|
+
const {
|
|
550
|
+
alignment,
|
|
551
|
+
light,
|
|
552
|
+
style,
|
|
553
|
+
className,
|
|
554
|
+
dropdownStyle,
|
|
555
|
+
children,
|
|
556
|
+
isFilterable,
|
|
557
|
+
} = this.props;
|
|
558
|
+
const {open, searchText} = this.state;
|
|
559
|
+
const {noResults} = this.state.labels;
|
|
560
|
+
|
|
561
|
+
const allChildren = React.Children.toArray(children).filter(Boolean);
|
|
562
|
+
const numOptions = allChildren.length;
|
|
563
|
+
const filteredItems = this.getMenuItems(allChildren);
|
|
564
|
+
const opener = this.renderOpener(allChildren);
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<DropdownCore
|
|
568
|
+
role="listbox"
|
|
569
|
+
alignment={alignment}
|
|
570
|
+
dropdownStyle={[
|
|
571
|
+
isFilterable && filterableDropdownStyle,
|
|
572
|
+
selectDropdownStyle,
|
|
573
|
+
dropdownStyle,
|
|
574
|
+
]}
|
|
575
|
+
items={[
|
|
576
|
+
...this.getSearchField(),
|
|
577
|
+
...this.getShortcuts(numOptions),
|
|
578
|
+
...filteredItems,
|
|
579
|
+
]}
|
|
580
|
+
light={light}
|
|
581
|
+
onOpenChanged={this.handleOpenChanged}
|
|
582
|
+
open={open}
|
|
583
|
+
opener={opener}
|
|
584
|
+
openerElement={this.state.openerElement}
|
|
585
|
+
style={style}
|
|
586
|
+
className={className}
|
|
587
|
+
onSearchTextChanged={
|
|
588
|
+
isFilterable ? this.handleSearchTextChanged : null
|
|
589
|
+
}
|
|
590
|
+
searchText={isFilterable ? searchText : ""}
|
|
591
|
+
labels={{
|
|
592
|
+
noResults,
|
|
593
|
+
}}
|
|
594
|
+
/>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|