@khanacademy/wonder-blocks-dropdown 2.7.3 → 2.7.6

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 (29) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/es/index.js +92 -162
  3. package/dist/index.js +285 -374
  4. package/package.json +6 -6
  5. package/src/components/__docs__/action-menu.argtypes.js +44 -0
  6. package/src/components/__docs__/action-menu.stories.js +435 -0
  7. package/src/components/__docs__/base-select.argtypes.js +54 -0
  8. package/src/components/__docs__/multi-select.stories.js +509 -0
  9. package/src/components/__docs__/single-select.accessibility.stories.mdx +59 -0
  10. package/src/components/__docs__/single-select.argtypes.js +54 -0
  11. package/src/components/__docs__/single-select.stories.js +464 -0
  12. package/src/components/__tests__/dropdown-core-virtualized.test.js +0 -15
  13. package/src/components/__tests__/dropdown-core.test.js +114 -208
  14. package/src/components/__tests__/multi-select.test.js +1 -3
  15. package/src/components/__tests__/single-select.test.js +15 -47
  16. package/src/components/action-menu.js +11 -0
  17. package/src/components/dropdown-core-virtualized.js +0 -5
  18. package/src/components/dropdown-core.js +140 -126
  19. package/src/components/multi-select.js +17 -33
  20. package/src/components/single-select.js +15 -30
  21. package/src/util/__tests__/dropdown-menu-styles.test.js +0 -26
  22. package/src/util/constants.js +0 -11
  23. package/src/util/dropdown-menu-styles.js +0 -5
  24. package/src/util/types.js +2 -5
  25. package/src/components/__tests__/search-text-input.test.js +0 -212
  26. package/src/components/action-menu.stories.js +0 -48
  27. package/src/components/multi-select.stories.js +0 -124
  28. package/src/components/search-text-input.js +0 -115
  29. package/src/components/single-select.stories.js +0 -247
@@ -0,0 +1,509 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {StyleSheet} from "aphrodite";
4
+
5
+ import {View} from "@khanacademy/wonder-blocks-core";
6
+
7
+ import type {Labels} from "@khanacademy/wonder-blocks-dropdown";
8
+ import type {StoryComponentType} from "@storybook/react";
9
+
10
+ import Button from "@khanacademy/wonder-blocks-button";
11
+ import Color from "@khanacademy/wonder-blocks-color";
12
+ import {MultiSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
13
+ import {Checkbox} from "@khanacademy/wonder-blocks-form";
14
+ import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";
15
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
16
+ import {HeadingLarge} from "@khanacademy/wonder-blocks-typography";
17
+
18
+ import ComponentInfo from "../../../../../.storybook/components/component-info.js";
19
+ import {name, version} from "../../../package.json";
20
+ import multiSelectArgtypes from "./base-select.argtypes.js";
21
+ import {defaultLabels} from "../../util/constants.js";
22
+
23
+ export default {
24
+ title: "Dropdown / MultiSelect",
25
+ component: MultiSelect,
26
+ argTypes: {
27
+ ...multiSelectArgtypes,
28
+ labels: {
29
+ defaultValue: defaultLabels,
30
+ },
31
+ },
32
+ args: {
33
+ isFilterable: false,
34
+ opened: false,
35
+ disabled: false,
36
+ light: false,
37
+ shortcuts: false,
38
+ implicitAllEnabled: false,
39
+ id: "",
40
+ testId: "",
41
+ },
42
+ decorators: [
43
+ (Story: StoryComponentType): React.Element<typeof View> => (
44
+ <View style={styles.example}>
45
+ <Story />
46
+ </View>
47
+ ),
48
+ ],
49
+ parameters: {
50
+ componentSubtitle: ((
51
+ <ComponentInfo name={name} version={version} />
52
+ ): any),
53
+ docs: {
54
+ description: {
55
+ component: null,
56
+ },
57
+ source: {
58
+ // See https://github.com/storybookjs/storybook/issues/12596
59
+ excludeDecorators: true,
60
+ },
61
+ },
62
+ },
63
+ };
64
+
65
+ const styles = StyleSheet.create({
66
+ example: {
67
+ background: Color.offWhite,
68
+ padding: Spacing.medium_16,
69
+ },
70
+ setWidth: {
71
+ minWidth: 170,
72
+ width: "100%",
73
+ },
74
+ customDropdown: {
75
+ maxHeight: 200,
76
+ },
77
+ wrapper: {
78
+ height: "400px",
79
+ width: "600px",
80
+ },
81
+ centered: {
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ },
85
+ scrolledWrapper: {
86
+ height: 200,
87
+ overflow: "auto",
88
+ border: "1px solid grey",
89
+ borderRadius: Spacing.xxxSmall_4,
90
+ margin: Spacing.xSmall_8,
91
+ padding: Spacing.medium_16,
92
+ },
93
+ scrollableArea: {
94
+ height: "200vh",
95
+ },
96
+ /**
97
+ * Custom opener styles
98
+ */
99
+ customOpener: {
100
+ borderLeft: `5px solid ${Color.blue}`,
101
+ borderRadius: Spacing.xxxSmall_4,
102
+ background: Color.lightBlue,
103
+ color: Color.white,
104
+ padding: Spacing.medium_16,
105
+ },
106
+ focused: {
107
+ color: Color.offWhite,
108
+ },
109
+ hovered: {
110
+ textDecoration: "underline",
111
+ color: Color.offWhite,
112
+ cursor: "pointer",
113
+ },
114
+ pressed: {
115
+ color: Color.blue,
116
+ },
117
+ });
118
+
119
+ const items = [
120
+ <OptionItem label="Mercury" value="1" />,
121
+ <OptionItem label="Venus" value="2" />,
122
+ <OptionItem label="Earth" value="3" disabled />,
123
+ <OptionItem label="Mars" value="4" />,
124
+ <OptionItem label="Jupiter" value="5" />,
125
+ <OptionItem label="Saturn" value="6" />,
126
+ <OptionItem label="Neptune" value="7" />,
127
+ <OptionItem label="Uranus" value="8" />,
128
+ ];
129
+
130
+ const Template = (args) => {
131
+ const [selectedValues, setSelectedValues] = React.useState(
132
+ args.selectedValues,
133
+ );
134
+ const [opened, setOpened] = React.useState(args.opened);
135
+ React.useEffect(() => {
136
+ // Only update opened if the args.opened prop changes (using the
137
+ // controls panel).
138
+ setOpened(args.opened);
139
+ }, [args.opened]);
140
+
141
+ return (
142
+ <View style={styles.wrapper}>
143
+ <MultiSelect
144
+ {...args}
145
+ onChange={setSelectedValues}
146
+ selectedValues={selectedValues}
147
+ opened={opened}
148
+ onToggle={setOpened}
149
+ >
150
+ {items}
151
+ </MultiSelect>
152
+ </View>
153
+ );
154
+ };
155
+
156
+ export const Default: StoryComponentType = Template.bind({});
157
+
158
+ export const ControlledOpened: StoryComponentType = (args) => {
159
+ const [selectedValues, setSelectedValues] = React.useState([]);
160
+ const [opened, setOpened] = React.useState(args.opened);
161
+ React.useEffect(() => {
162
+ // Only update opened if the args.opened prop changes (using the
163
+ // controls panel).
164
+ setOpened(args.opened);
165
+ }, [args.opened]);
166
+
167
+ return (
168
+ <View style={styles.wrapper}>
169
+ <Checkbox label="Open" onChange={setOpened} checked={opened} />
170
+ <MultiSelect
171
+ {...args}
172
+ onChange={setSelectedValues}
173
+ selectedValue={selectedValues}
174
+ opened={opened}
175
+ onToggle={setOpened}
176
+ >
177
+ {items}
178
+ </MultiSelect>
179
+ </View>
180
+ );
181
+ };
182
+
183
+ ControlledOpened.args = {
184
+ opened: true,
185
+ };
186
+
187
+ ControlledOpened.storyName = "Controlled (opened)";
188
+
189
+ ControlledOpened.parameters = {
190
+ docs: {
191
+ description: {
192
+ story:
193
+ "Sometimes you'll want to trigger a dropdown programmatically. This can be done by `MultiSelect` is a controlled component. The parent is responsible for managing the opening/closing of the dropdown when using this prop.\n\n" +
194
+ "This means that you'll also have to update `opened` to the value triggered by the `onToggle` prop.",
195
+ },
196
+ },
197
+ // Added to ensure that the dropdown menu is rendered using PopperJS.
198
+ chromatic: {delay: 400},
199
+ };
200
+
201
+ // Custom MultiSelect labels
202
+ const dropdownLabels: $Shape<Labels> = {
203
+ noneSelected: "Solar system",
204
+ someSelected: (numSelectedValues) => `${numSelectedValues} planets`,
205
+ };
206
+
207
+ export const CustomStyles: StoryComponentType = Template.bind({});
208
+
209
+ CustomStyles.args = {
210
+ labels: dropdownLabels,
211
+ dropdownStyle: styles.customDropdown,
212
+ style: styles.setWidth,
213
+ };
214
+
215
+ CustomStyles.parameters = {
216
+ docs: {
217
+ description: {
218
+ story: "Sometimes, we may want to customize the dropdown style (for example, to limit the height of the list). For this purpose, we have the `dropdownStyle` prop.",
219
+ },
220
+ },
221
+ chromatic: {
222
+ // we don't need screenshots because this story only tests behavior.
223
+ disableSnapshot: true,
224
+ },
225
+ };
226
+
227
+ export const CustomStylesOpened: StoryComponentType = Template.bind({});
228
+
229
+ CustomStylesOpened.args = {
230
+ labels: dropdownLabels,
231
+ dropdownStyle: styles.customDropdown,
232
+ style: styles.setWidth,
233
+ opened: true,
234
+ };
235
+
236
+ CustomStylesOpened.storyName = "Custom styles (opened)";
237
+
238
+ CustomStylesOpened.parameters = {
239
+ docs: {
240
+ description: {
241
+ story: "Here you can see an example of the previous dropdown opened",
242
+ },
243
+ },
244
+ };
245
+
246
+ /**
247
+ * With shortcuts
248
+ */
249
+ export const Shortcuts: StoryComponentType = Template.bind({});
250
+
251
+ Shortcuts.args = {
252
+ shortcuts: true,
253
+ opened: true,
254
+ };
255
+
256
+ Shortcuts.parameters = {
257
+ docs: {
258
+ description: {
259
+ story: "This example starts with one item selected and has selection shortcuts for select all and select none. This one does not have a predefined placeholder.",
260
+ },
261
+ },
262
+ };
263
+
264
+ /**
265
+ * In a Modal
266
+ */
267
+ export const DropdownInModal: StoryComponentType = (args) => {
268
+ const [selectedValues, setSelectedValues] = React.useState([]);
269
+ const [opened, setOpened] = React.useState(true);
270
+
271
+ const modalContent = (
272
+ <View style={styles.scrollableArea}>
273
+ <View style={styles.scrolledWrapper}>
274
+ <View style={{minHeight: "100vh"}}>
275
+ <MultiSelect
276
+ {...args}
277
+ onChange={setSelectedValues}
278
+ isFilterable={true}
279
+ opened={opened}
280
+ onToggle={setOpened}
281
+ selectedValues={selectedValues}
282
+ >
283
+ {items}
284
+ </MultiSelect>
285
+ </View>
286
+ </View>
287
+ </View>
288
+ );
289
+
290
+ const modal = (
291
+ <OnePaneDialog title="Dropdown in a Modal" content={modalContent} />
292
+ );
293
+
294
+ return (
295
+ <View style={styles.centered}>
296
+ <ModalLauncher modal={modal}>
297
+ {({openModal}) => (
298
+ <Button onClick={openModal}>Click here!</Button>
299
+ )}
300
+ </ModalLauncher>
301
+ </View>
302
+ );
303
+ };
304
+
305
+ DropdownInModal.storyName = "Dropdown in a modal";
306
+
307
+ DropdownInModal.parameters = {
308
+ docs: {
309
+ description: {
310
+ story: "Sometimes we want to include Dropdowns inside a Modal, and these controls can be accessed only by scrolling down. This example help us to demonstrate that `MultiSelect` components can correctly be displayed within the visible scrolling area.",
311
+ },
312
+ },
313
+ chromatic: {
314
+ // We don't need screenshots because this story can be tested after
315
+ // the modal is opened.
316
+ disableSnapshot: true,
317
+ },
318
+ };
319
+
320
+ /**
321
+ * Disabled
322
+ */
323
+ export const Disabled: StoryComponentType = () => (
324
+ <MultiSelect disabled={true} onChange={() => {}}>
325
+ <OptionItem label="Mercury" value="1" />
326
+ <OptionItem label="Venus" value="2" />
327
+ </MultiSelect>
328
+ );
329
+
330
+ Disabled.parameters = {
331
+ docs: {
332
+ storyDescription:
333
+ "`MultiSelect` can be disabled by passing `disabled={true}`. This can be useful when you want to disable a control temporarily.",
334
+ },
335
+ };
336
+
337
+ /**
338
+ * ImplicitAll enabled
339
+ */
340
+ export const ImplicitAllEnabled: StoryComponentType = Template.bind({});
341
+
342
+ ImplicitAllEnabled.args = {
343
+ implicitAllEnabled: true,
344
+ labels: {
345
+ someSelected: (numSelectedValues) => `${numSelectedValues} fruits`,
346
+ allSelected: "All planets selected",
347
+ },
348
+ };
349
+
350
+ ImplicitAllEnabled.parameters = {
351
+ docs: {
352
+ description: {
353
+ story: `When nothing is selected, show the menu text as "All selected". Note that the actual selection logic doesn't change. (Only the menu text)`,
354
+ },
355
+ },
356
+ chromatic: {
357
+ // We don't need screenshots b/c the dropdown is initially closed.
358
+ disableSnapshot: true,
359
+ },
360
+ };
361
+
362
+ /**
363
+ * Virtualized with search filter
364
+ */
365
+ const fruits = ["banana", "strawberry", "pear", "orange"];
366
+
367
+ const optionItems = new Array(1000)
368
+ .fill(null)
369
+ .map((_, i) => (
370
+ <OptionItem
371
+ key={i}
372
+ value={(i + 1).toString()}
373
+ label={`Fruit # ${i + 1} ${fruits[i % fruits.length]}`}
374
+ />
375
+ ));
376
+
377
+ type Props = {|
378
+ opened?: boolean,
379
+ |};
380
+
381
+ function VirtualizedMultiSelect(props: Props) {
382
+ const [selectedValues, setSelectedValues] = React.useState([]);
383
+ const [opened, setOpened] = React.useState(props.opened || false);
384
+
385
+ return (
386
+ <View style={styles.wrapper}>
387
+ <MultiSelect
388
+ onChange={setSelectedValues}
389
+ shortcuts={true}
390
+ isFilterable={true}
391
+ opened={opened}
392
+ onToggle={setOpened}
393
+ selectedValues={selectedValues}
394
+ >
395
+ {optionItems}
396
+ </MultiSelect>
397
+ </View>
398
+ );
399
+ }
400
+
401
+ /**
402
+ * Virtualized MultiSelect
403
+ */
404
+ export const VirtualizedFilterable: StoryComponentType = () => (
405
+ <VirtualizedMultiSelect opened={true} />
406
+ );
407
+
408
+ VirtualizedFilterable.storyName = "Virtualized (isFilterable)";
409
+
410
+ VirtualizedFilterable.parameters = {
411
+ docs: {
412
+ description: {
413
+ story: "When there are many options, you could use a search filter in the `MultiSelect`. The search filter will be performed toward the labels of the option items. Note that this example shows how we can add custom styles to the dropdown as well.",
414
+ },
415
+ },
416
+ };
417
+
418
+ /**
419
+ * Custom opener
420
+ */
421
+ export const CustomOpener: StoryComponentType = Template.bind({});
422
+
423
+ CustomOpener.args = {
424
+ selectedValue: "",
425
+ opener: ({focused, hovered, pressed, text}) => (
426
+ <HeadingLarge
427
+ onClick={() => {
428
+ // eslint-disable-next-line no-console
429
+ console.log("custom click!!!!!");
430
+ }}
431
+ style={[
432
+ styles.customOpener,
433
+ focused && styles.focused,
434
+ hovered && styles.hovered,
435
+ pressed && styles.pressed,
436
+ ]}
437
+ >
438
+ {text}
439
+ </HeadingLarge>
440
+ ),
441
+ };
442
+
443
+ CustomOpener.storyName = "With custom opener";
444
+
445
+ CustomOpener.parameters = {
446
+ docs: {
447
+ description: {
448
+ story:
449
+ "In case you need to use a custom opener with the `MultiSelect`, you can use the opener property to achieve this. In this example, the opener prop accepts a function with the following arguments:\n" +
450
+ "- `eventState`: lets you customize the style for different states, such as pressed, hovered and focused.\n" +
451
+ "- `text`: Passes the menu label defined in the parent component. This value is passed using the placeholder prop set in the `MultiSelect` component.\n\n" +
452
+ "**Note:** If you need to use a custom ID for testing the opener, make sure to pass the testId prop inside the opener component/element.",
453
+ },
454
+ },
455
+ };
456
+
457
+ /**
458
+ * Custom labels
459
+ */
460
+ const translatedItems = new Array(10)
461
+ .fill(null)
462
+ .map((_, i) => (
463
+ <OptionItem
464
+ key={i}
465
+ value={(i + 1).toString()}
466
+ label={`Escuela # ${i + 1}`}
467
+ />
468
+ ));
469
+
470
+ export const CustomLabels: StoryComponentType = () => {
471
+ const [selectedValues, setSelectedValues] = React.useState([]);
472
+ const [opened, setOpened] = React.useState(true);
473
+
474
+ const labels: $Shape<Labels> = {
475
+ clearSearch: "Limpiar busqueda",
476
+ filter: "Filtrar",
477
+ noResults: "Sin resultados",
478
+ selectAllLabel: (numOptions) => `Seleccionar todas (${numOptions})`,
479
+ selectNoneLabel: "No seleccionar ninguno",
480
+ noneSelected: "0 escuelas seleccionadas",
481
+ allSelected: "Todas las escuelas",
482
+ someSelected: (numSelectedValues) =>
483
+ `${numSelectedValues} escuelas seleccionadas`,
484
+ };
485
+
486
+ return (
487
+ <View style={styles.wrapper}>
488
+ <MultiSelect
489
+ shortcuts={true}
490
+ isFilterable={true}
491
+ onChange={setSelectedValues}
492
+ selectedValues={selectedValues}
493
+ labels={labels}
494
+ opened={opened}
495
+ onToggle={setOpened}
496
+ >
497
+ {translatedItems}
498
+ </MultiSelect>
499
+ </View>
500
+ );
501
+ };
502
+
503
+ CustomLabels.parameters = {
504
+ docs: {
505
+ description: {
506
+ story: "This example illustrates how you can pass custom labels to the MultiSelect component.",
507
+ },
508
+ },
509
+ };
@@ -0,0 +1,59 @@
1
+ import {Meta, Story, Canvas} from "@storybook/addon-docs";
2
+
3
+ import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown";
4
+ import {View} from "@khanacademy/wonder-blocks-core";
5
+ import {LabelLarge} from "@khanacademy/wonder-blocks-typography";
6
+
7
+ <Meta
8
+ title="Dropdown / SingleSelect / Accessibility"
9
+ component={SingleSelect}
10
+ parameters={{
11
+ previewTabs: {
12
+ canvas: {hidden: true},
13
+ },
14
+ viewMode: "docs",
15
+ chromatic: {
16
+ // Disables chromatic testing for these stories.
17
+ disableSnapshot: true,
18
+ },
19
+ }}
20
+ />
21
+
22
+ export const SingleSelectAccessibility = () => (
23
+ <View>
24
+ <LabelLarge
25
+ tag="label"
26
+ id="label-for-single-select"
27
+ htmlFor="unique-single-select"
28
+ >
29
+ Associated label element
30
+ </LabelLarge>
31
+ <SingleSelect
32
+ aria-labelledby="label-for-single-select"
33
+ id="unique-single-select"
34
+ placeholder="Accessible SingleSelect"
35
+ selectedValue="one"
36
+ >
37
+ <OptionItem label="First element" aria-label="First element, selected" value="one" />
38
+ <OptionItem label="Second element" aria-label="Second element, unselelected" value="two" />
39
+ </SingleSelect>
40
+ </View>
41
+ );
42
+
43
+ # Accessibility
44
+
45
+ If you need to associate this component with another element (e.g. `<label>`),
46
+ make sure to pass the `aria-labelledby` and/or `id` props to the `SingleSelect` component.
47
+ This way, the `opener` will receive this value and it will associate both
48
+ elements.
49
+
50
+ Also, if you need screen readers to understand any relevant information on every
51
+ option item, you can use `aria-label` on each item. e.g. You can use it to let
52
+ screen readers know the current selected/unselected status of the item when it
53
+ receives focus.
54
+
55
+ <Canvas>
56
+ <Story name="Using aria attributes">
57
+ {SingleSelectAccessibility.bind({})}
58
+ </Story>
59
+ </Canvas>
@@ -0,0 +1,54 @@
1
+ // @flow
2
+ export default {
3
+ alignment: {
4
+ table: {
5
+ category: "Layout",
6
+ },
7
+ },
8
+ disabled: {
9
+ table: {
10
+ category: "States",
11
+ },
12
+ },
13
+ isFilterable: {
14
+ table: {
15
+ category: "States",
16
+ },
17
+ },
18
+ light: {
19
+ table: {
20
+ category: "States",
21
+ },
22
+ },
23
+ opened: {
24
+ control: "boolean",
25
+ table: {
26
+ category: "States",
27
+ },
28
+ },
29
+ onToggle: {
30
+ table: {
31
+ category: "Events",
32
+ },
33
+ },
34
+ onChange: {
35
+ table: {
36
+ category: "Events",
37
+ },
38
+ },
39
+ dropdownStyle: {
40
+ table: {
41
+ category: "Styling",
42
+ },
43
+ },
44
+ style: {
45
+ table: {
46
+ category: "Styling",
47
+ },
48
+ },
49
+ className: {
50
+ table: {
51
+ category: "Styling",
52
+ },
53
+ },
54
+ };