@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,111 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {StyleSheet} from "aphrodite";
4
+
5
+ import {View} from "@khanacademy/wonder-blocks-core";
6
+ import {MultiSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
7
+
8
+ import type {Labels} from "@khanacademy/wonder-blocks-dropdown";
9
+ import type {StoryComponentType} from "@storybook/react";
10
+
11
+ export default {
12
+ title: "Dropdown / MultiSelect",
13
+ };
14
+
15
+ // Custom MultiSelect labels
16
+ const dropdownLabels: $Shape<Labels> = {
17
+ noneSelected: "Solar system",
18
+ someSelected: (numSelectedValues) => `${numSelectedValues} planets`,
19
+ };
20
+
21
+ type Props = {|
22
+ opened: boolean,
23
+ |};
24
+
25
+ type State = {|
26
+ opened: boolean,
27
+ selectedValues: Array<string>,
28
+ |};
29
+
30
+ type DefaultProps = {|
31
+ opened: $PropertyType<Props, "opened">,
32
+ |};
33
+
34
+ class MultiSelectWithCustomStyles extends React.Component<Props, State> {
35
+ static defaultProps: DefaultProps = {
36
+ opened: false,
37
+ };
38
+
39
+ state: State = {
40
+ selectedValues: [],
41
+ opened: this.props.opened,
42
+ };
43
+
44
+ handleChange: (update: Array<string>) => void = (update) => {
45
+ this.setState({
46
+ selectedValues: update,
47
+ });
48
+ };
49
+
50
+ handleToggleMenu: (opened: boolean) => void = (opened) => {
51
+ this.setState({
52
+ opened,
53
+ });
54
+ };
55
+
56
+ render(): React.Node {
57
+ return (
58
+ <View style={styles.wrapper}>
59
+ <MultiSelect
60
+ onChange={this.handleChange}
61
+ selectedValues={this.state.selectedValues}
62
+ style={styles.setWidth}
63
+ dropdownStyle={styles.customDropdown}
64
+ labels={dropdownLabels}
65
+ opened={this.state.opened}
66
+ onToggle={this.handleToggleMenu}
67
+ >
68
+ <OptionItem label="Mercury" value="1" />
69
+ <OptionItem label="Venus" value="2" />
70
+ <OptionItem label="Earth" value="3" disabled />
71
+ <OptionItem label="Mars" value="4" />
72
+ <OptionItem label="Jupiter" value="5" />
73
+ <OptionItem label="Saturn" value="6" />
74
+ <OptionItem label="Neptune" value="7" />
75
+ <OptionItem label="Uranus" value="8" />
76
+ </MultiSelect>
77
+ </View>
78
+ );
79
+ }
80
+ }
81
+
82
+ export const customStyles: StoryComponentType = () => (
83
+ <MultiSelectWithCustomStyles />
84
+ );
85
+
86
+ customStyles.story = {
87
+ parameters: {
88
+ chromatic: {
89
+ // we don't need screenshots because this story only tests behavior.
90
+ disable: true,
91
+ },
92
+ },
93
+ };
94
+
95
+ export const customStylesOpened: StoryComponentType = () => (
96
+ <MultiSelectWithCustomStyles opened={true} />
97
+ );
98
+
99
+ const styles = StyleSheet.create({
100
+ setWidth: {
101
+ minWidth: 170,
102
+ width: "100%",
103
+ },
104
+ customDropdown: {
105
+ maxHeight: 200,
106
+ },
107
+ wrapper: {
108
+ height: "800px",
109
+ width: "1200px",
110
+ },
111
+ });
@@ -0,0 +1,239 @@
1
+ // @flow
2
+
3
+ import * as React from "react";
4
+ import {StyleSheet} from "aphrodite";
5
+ import * as PropTypes from "prop-types";
6
+
7
+ import Color, {mix, fade} from "@khanacademy/wonder-blocks-color";
8
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
9
+ import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
10
+ import {View} from "@khanacademy/wonder-blocks-core";
11
+ import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable";
12
+
13
+ import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
14
+
15
+ import {DROPDOWN_ITEM_HEIGHT} from "../util/constants.js";
16
+ import Check from "./check.js";
17
+ import Checkbox from "./checkbox.js";
18
+
19
+ type OptionProps = {|
20
+ ...AriaProps,
21
+
22
+ /**
23
+ * Display text of the option item.
24
+ */
25
+ label: string,
26
+
27
+ /**
28
+ * Value of the item, used as a key of sorts for the parent to manage its
29
+ * items, because label/display text may be identical for some selects. This
30
+ * is the value passed back when the item is selected.
31
+ */
32
+ value: string,
33
+
34
+ /**
35
+ * Whether this option item is disabled.
36
+ */
37
+ disabled: boolean,
38
+
39
+ /**
40
+ * Optional user-supplied callback when this item is called.
41
+ */
42
+ onClick?: () => mixed,
43
+
44
+ /**
45
+ * Callback for when this item is pressed to change its selection state.
46
+ * Passes value of the item. Auto-populated by menu or select.
47
+ * @ignore
48
+ */
49
+ onToggle: (value: string) => mixed,
50
+
51
+ /**
52
+ * Whether this item is selected. Auto-populated by menu or select.
53
+ * @ignore
54
+ */
55
+ selected: boolean,
56
+
57
+ /**
58
+ * Aria role to use, defaults to "option".
59
+ */
60
+ role: "menuitem" | "option",
61
+
62
+ /**
63
+ * Test ID used for e2e testing.
64
+ */
65
+ testId?: string,
66
+
67
+ /**
68
+ * Whether the item should show a check or checkbox to indicate selection
69
+ * state. Auto-populated by menu or select.
70
+ * @ignore
71
+ */
72
+ variant?: "check" | "checkbox",
73
+
74
+ /**
75
+ * In case we use react-window, this needs to be added in order to inject
76
+ * styles to calculate the position
77
+ * @ignore
78
+ */
79
+ style?: StyleType,
80
+ |};
81
+
82
+ type ContextTypes = {|
83
+ router: $FlowFixMe,
84
+ |};
85
+
86
+ type DefaultProps = {|
87
+ disabled: $PropertyType<OptionProps, "disabled">,
88
+ onToggle: $PropertyType<OptionProps, "onToggle">,
89
+ role: $PropertyType<OptionProps, "role">,
90
+ selected: $PropertyType<OptionProps, "selected">,
91
+ |};
92
+
93
+ /**
94
+ * For option items that can be selected in a dropdown, selection denoted either
95
+ * with a check ✔️ or a checkbox ☑️. Use as children in SingleSelect or
96
+ * MultiSelect.
97
+ */
98
+ export default class OptionItem extends React.Component<OptionProps> {
99
+ static isClassOf(instance: React.Element<any>): boolean {
100
+ return instance && instance.type && instance.type.__IS_OPTION_ITEM__;
101
+ }
102
+ static contextTypes: ContextTypes = {router: PropTypes.any};
103
+ static defaultProps: DefaultProps = {
104
+ disabled: false,
105
+ onToggle: () => void 0,
106
+ role: "option",
107
+ selected: false,
108
+ };
109
+ static __IS_OPTION_ITEM__: boolean = true;
110
+
111
+ getCheckComponent(): typeof Check | typeof Checkbox {
112
+ if (this.props.variant === "check") {
113
+ return Check;
114
+ } else {
115
+ return Checkbox;
116
+ }
117
+ }
118
+
119
+ handleClick: () => void = () => {
120
+ const {onClick, onToggle, value} = this.props;
121
+ onToggle(value);
122
+ if (onClick) {
123
+ onClick();
124
+ }
125
+ };
126
+
127
+ render(): React.Node {
128
+ const {
129
+ disabled,
130
+ label,
131
+ role,
132
+ selected,
133
+ testId,
134
+ style,
135
+ // eslint-disable-next-line no-unused-vars
136
+ value,
137
+ /* eslint-disable no-unused-vars */
138
+ onClick,
139
+ onToggle,
140
+ variant,
141
+ /* eslint-enable no-unused-vars */
142
+ ...sharedProps
143
+ } = this.props;
144
+
145
+ const ClickableBehavior = getClickableBehavior();
146
+ const CheckComponent = this.getCheckComponent();
147
+
148
+ return (
149
+ <ClickableBehavior
150
+ disabled={disabled}
151
+ onClick={this.handleClick}
152
+ role={role}
153
+ >
154
+ {(state, childrenProps) => {
155
+ const {pressed, hovered, focused} = state;
156
+
157
+ const defaultStyle = [
158
+ styles.itemContainer,
159
+ pressed
160
+ ? styles.active
161
+ : (hovered || focused) && styles.focus,
162
+ disabled && styles.disabled,
163
+ // pass optional styles from react-window (if applies)
164
+ style,
165
+ ];
166
+
167
+ return (
168
+ <View
169
+ {...sharedProps}
170
+ testId={testId}
171
+ style={defaultStyle}
172
+ aria-selected={selected ? "true" : "false"}
173
+ role={role}
174
+ {...childrenProps}
175
+ >
176
+ <CheckComponent
177
+ disabled={disabled}
178
+ selected={selected}
179
+ pressed={pressed}
180
+ hovered={hovered}
181
+ focused={focused}
182
+ />
183
+ <LabelMedium style={styles.label}>
184
+ {label}
185
+ </LabelMedium>
186
+ </View>
187
+ );
188
+ }}
189
+ </ClickableBehavior>
190
+ );
191
+ }
192
+ }
193
+
194
+ const {blue, white, offBlack, offBlack32} = Color;
195
+
196
+ const styles = StyleSheet.create({
197
+ itemContainer: {
198
+ flexDirection: "row",
199
+ backgroundColor: white,
200
+ color: offBlack,
201
+ alignItems: "center",
202
+ height: DROPDOWN_ITEM_HEIGHT,
203
+ minHeight: DROPDOWN_ITEM_HEIGHT,
204
+ border: 0,
205
+ outline: 0,
206
+ paddingLeft: Spacing.xSmall_8,
207
+ paddingRight: Spacing.medium_16,
208
+ whiteSpace: "nowrap",
209
+ cursor: "default",
210
+ },
211
+
212
+ focus: {
213
+ color: white,
214
+ background: blue,
215
+ },
216
+
217
+ active: {
218
+ color: mix(fade(blue, 0.32), white),
219
+ background: mix(offBlack32, blue),
220
+ },
221
+
222
+ disabled: {
223
+ color: offBlack32,
224
+ background: white,
225
+ },
226
+
227
+ label: {
228
+ whiteSpace: "nowrap",
229
+ userSelect: "none",
230
+ marginLeft: Spacing.xSmall_8,
231
+ // added to truncate strings that are longer than expected
232
+ overflow: "hidden",
233
+ textOverflow: "ellipsis",
234
+ },
235
+
236
+ hide: {
237
+ visibility: "hidden",
238
+ },
239
+ });
@@ -0,0 +1,227 @@
1
+ // @flow
2
+ // A TextField with a search icon on its left side and X icon on its right side
3
+
4
+ import * as React from "react";
5
+ import {StyleSheet, css} from "aphrodite";
6
+
7
+ import {styles as typographyStyles} from "@khanacademy/wonder-blocks-typography";
8
+ import {View} from "@khanacademy/wonder-blocks-core";
9
+ import IconButton from "@khanacademy/wonder-blocks-icon-button";
10
+ import Icon, {icons} from "@khanacademy/wonder-blocks-icon";
11
+ import Color from "@khanacademy/wonder-blocks-color";
12
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
13
+ import type {StyleType} from "@khanacademy/wonder-blocks-core";
14
+
15
+ import {defaultLabels, DROPDOWN_ITEM_HEIGHT} from "../util/constants.js";
16
+
17
+ type Labels = {|
18
+ clearSearch: string,
19
+ filter: string,
20
+ |};
21
+
22
+ type Props = {|
23
+ /**
24
+ * The object containing the custom labels used inside this component.
25
+ */
26
+ labels: Labels,
27
+
28
+ /**
29
+ * the text input
30
+ */
31
+ searchText: string,
32
+
33
+ /**
34
+ * Called when the input value is changed
35
+ */
36
+ onChange: (searchText: string) => mixed,
37
+
38
+ /**
39
+ * Handler that is triggered when this component is clicked. For example,
40
+ * use this to adjust focus in parent component. This gets called when we
41
+ * click the dismiss icon button within the SearchTextInput.
42
+ */
43
+ onClick?: () => mixed,
44
+
45
+ /**
46
+ * Used to handle the focus order in its parent component. The itemRef is
47
+ * applied to the input directly.
48
+ */
49
+ itemRef?: {|current: any|},
50
+
51
+ /**
52
+ * Custom styles for the main wrapper
53
+ */
54
+ style?: StyleType,
55
+
56
+ /**
57
+ * Test ID used for e2e testing.
58
+ */
59
+ testId?: string,
60
+ |};
61
+
62
+ type DefaultProps = {|
63
+ labels: $PropertyType<Props, "labels">,
64
+ |};
65
+
66
+ type State = {|
67
+ focused: boolean,
68
+
69
+ /**
70
+ * The object containing the custom labels used inside this component.
71
+ */
72
+ labels: Labels,
73
+ |};
74
+
75
+ export default class SearchTextInput extends React.Component<Props, State> {
76
+ static isClassOf(instance: React.Element<any>): boolean {
77
+ return (
78
+ instance && instance.type && instance.type.__IS_SEARCH_TEXT_INPUT__
79
+ );
80
+ }
81
+
82
+ static defaultProps: DefaultProps = {
83
+ labels: {
84
+ clearSearch: defaultLabels.clearSearch,
85
+ filter: defaultLabels.filter,
86
+ },
87
+ };
88
+
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
+ static __IS_SEARCH_TEXT_INPUT__: boolean = true;
108
+
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
+ render(): React.Node {
150
+ const {onClick, itemRef, searchText, style, testId} = this.props;
151
+ const {filter} = this.state.labels;
152
+
153
+ return (
154
+ <View
155
+ onClick={onClick}
156
+ style={[
157
+ styles.inputContainer,
158
+ this.state.focused && styles.focused,
159
+ style,
160
+ ]}
161
+ >
162
+ <Icon
163
+ icon={icons.search}
164
+ size="medium"
165
+ color={Color.offBlack64}
166
+ style={styles.searchIcon}
167
+ aria-hidden="true"
168
+ />
169
+ <input
170
+ type="text"
171
+ onChange={this.handleChange}
172
+ onFocus={this.handleFocus}
173
+ onBlur={this.handleBlur}
174
+ ref={itemRef}
175
+ placeholder={filter}
176
+ value={searchText}
177
+ className={css(
178
+ styles.inputStyleReset,
179
+ typographyStyles.LabelMedium,
180
+ )}
181
+ data-test-id={testId}
182
+ />
183
+ {this.maybeRenderDismissIconButton()}
184
+ </View>
185
+ );
186
+ }
187
+ }
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
+ });