@khanacademy/wonder-blocks-modal 2.3.4 → 2.3.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.
@@ -0,0 +1,512 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {StyleSheet} from "aphrodite";
4
+
5
+ import Button from "@khanacademy/wonder-blocks-button";
6
+ import {View} from "@khanacademy/wonder-blocks-core";
7
+ import {ActionMenu, ActionItem} from "@khanacademy/wonder-blocks-dropdown";
8
+ import {
9
+ LabeledTextField,
10
+ RadioGroup,
11
+ Choice,
12
+ } from "@khanacademy/wonder-blocks-form";
13
+ import {Strut} from "@khanacademy/wonder-blocks-layout";
14
+ import {ModalLauncher, OnePaneDialog} from "@khanacademy/wonder-blocks-modal";
15
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
16
+ import {Body, Title} from "@khanacademy/wonder-blocks-typography";
17
+
18
+ import type {StoryComponentType} from "@storybook/react";
19
+
20
+ import type {ModalElement} from "../../util/types.js";
21
+ import ModalLauncherArgTypes from "./modal-launcher.argtypes.js";
22
+
23
+ import ComponentInfo from "../../../../../.storybook/components/component-info.js";
24
+ import {name, version} from "../../../package.json";
25
+
26
+ const customViewports = {
27
+ phone: {
28
+ name: "phone",
29
+ styles: {
30
+ width: "320px",
31
+ height: "568px",
32
+ },
33
+ },
34
+ tablet: {
35
+ name: "tablet",
36
+ styles: {
37
+ width: "640px",
38
+ height: "960px",
39
+ },
40
+ },
41
+ desktop: {
42
+ name: "desktop",
43
+ styles: {
44
+ width: "1024px",
45
+ height: "768px",
46
+ },
47
+ },
48
+ };
49
+
50
+ const DefaultModal = (): ModalElement => (
51
+ <OnePaneDialog
52
+ title="Single-line title"
53
+ content={
54
+ <View>
55
+ <Body>
56
+ {`Lorem ipsum dolor sit amet, consectetur
57
+ adipiscing elit, sed do eiusmod tempor incididunt
58
+ ut labore et dolore magna aliqua. Ut enim ad minim
59
+ veniam, quis nostrud exercitation ullamco laboris
60
+ nisi ut aliquip ex ea commodo consequat. Duis aute
61
+ irure dolor in reprehenderit in voluptate velit
62
+ esse cillum dolore eu fugiat nulla pariatur.
63
+ Excepteur sint occaecat cupidatat non proident,
64
+ sunt in culpa qui officia deserunt mollit anim id
65
+ est.`}
66
+ </Body>
67
+ </View>
68
+ }
69
+ />
70
+ );
71
+
72
+ export default {
73
+ title: "Modal/ModalLauncher",
74
+ component: ModalLauncher,
75
+ decorators: [
76
+ (Story: StoryComponentType): React.Element<typeof View> => (
77
+ <View style={styles.example}>
78
+ <Story />
79
+ </View>
80
+ ),
81
+ ],
82
+ parameters: {
83
+ componentSubtitle: ((
84
+ <ComponentInfo name={name} version={version} />
85
+ ): any),
86
+ docs: {
87
+ description: {
88
+ component: null,
89
+ },
90
+ source: {
91
+ // See https://github.com/storybookjs/storybook/issues/12596
92
+ excludeDecorators: true,
93
+ },
94
+ },
95
+ viewport: {
96
+ viewports: customViewports,
97
+ defaultViewport: "desktop",
98
+ },
99
+ chromatic: {
100
+ viewports: [320, 640, 1024],
101
+ },
102
+ },
103
+ argTypes: ModalLauncherArgTypes,
104
+ };
105
+
106
+ export const Default: StoryComponentType = (args) => (
107
+ <ModalLauncher modal={DefaultModal} {...args}>
108
+ {({openModal}) => (
109
+ <Button onClick={openModal}>Click me to open the modal</Button>
110
+ )}
111
+ </ModalLauncher>
112
+ );
113
+
114
+ Default.parameters = {
115
+ chromatic: {
116
+ // All the examples for ModalLauncher are behavior based, not visual.
117
+ disableSnapshot: true,
118
+ },
119
+ };
120
+
121
+ export const Simple: StoryComponentType = () => (
122
+ <ModalLauncher modal={DefaultModal}>
123
+ {({openModal}) => (
124
+ <Button onClick={openModal}>Click me to open the modal</Button>
125
+ )}
126
+ </ModalLauncher>
127
+ );
128
+
129
+ Simple.parameters = {
130
+ chromatic: {
131
+ // All the examples for ModalLauncher are behavior based, not visual.
132
+ disableSnapshot: true,
133
+ },
134
+ docs: {
135
+ storyDescription: `This is a basic modal launcher. Its child, the
136
+ button, has access to the \`openModal\` function via the
137
+ function-as-child pattern. It passes this into its \`onClick\`
138
+ function, which causes the modal to launch when the button
139
+ is clicked.`,
140
+ },
141
+ };
142
+
143
+ export const WithCustomCloseButton: StoryComponentType = () => {
144
+ type MyModalProps = {|
145
+ closeModal: () => void,
146
+ |};
147
+
148
+ const ModalWithCloseButton = ({closeModal}: MyModalProps): ModalElement => (
149
+ <OnePaneDialog
150
+ title="Single-line title"
151
+ content={
152
+ <View>
153
+ <Body>
154
+ {`Lorem ipsum dolor sit amet, consectetur
155
+ adipiscing elit, sed do eiusmod tempor incididunt
156
+ ut labore et dolore magna aliqua. Ut enim ad minim
157
+ veniam, quis nostrud exercitation ullamco laboris
158
+ nisi ut aliquip ex ea commodo consequat. Duis aute
159
+ irure dolor in reprehenderit in voluptate velit
160
+ esse cillum dolore eu fugiat nulla pariatur.
161
+ Excepteur sint occaecat cupidatat non proident,
162
+ sunt in culpa qui officia deserunt mollit anim id
163
+ est.`}
164
+ </Body>
165
+ </View>
166
+ }
167
+ // No "X" close button in the top right corner
168
+ closeButtonVisible={false}
169
+ footer={<Button onClick={closeModal}>Close</Button>}
170
+ />
171
+ );
172
+
173
+ return (
174
+ <ModalLauncher modal={ModalWithCloseButton}>
175
+ {({openModal}) => (
176
+ <Button onClick={openModal}>Click me to open the modal</Button>
177
+ )}
178
+ </ModalLauncher>
179
+ );
180
+ };
181
+
182
+ WithCustomCloseButton.parameters = {
183
+ chromatic: {
184
+ // All the examples for ModalLauncher are behavior based, not visual.
185
+ disableSnapshot: true,
186
+ },
187
+ docs: {
188
+ storyDescription: `This is an example of a modal that uses
189
+ a close button other than the default "X" button in the top
190
+ right corner. Here, the default "X" close button is not rendered
191
+ because the \`closeButtonVisible\` prop on the \`<OnePaneDialog>\`
192
+ is set to false. Instead, a custom close button has been added
193
+ to the modal footer. The \`modal\` prop on \`<ModalLauncher>\`
194
+ can either be a plain modal, or it can be a function that takes
195
+ a \`closeModal\` function as a parameter and returns a modal.
196
+ The latter is what we do in this case. Then the \`closeModal\`
197
+ function is passed into the \`onClick\` prop on the button
198
+ in the footer.`,
199
+ },
200
+ };
201
+
202
+ export const WithBackdropDismissDisabled: StoryComponentType = () => (
203
+ <ModalLauncher modal={DefaultModal} backdropDismissEnabled={false}>
204
+ {({openModal}) => (
205
+ <Button onClick={openModal}>Click me to open the modal</Button>
206
+ )}
207
+ </ModalLauncher>
208
+ );
209
+
210
+ WithBackdropDismissDisabled.parameters = {
211
+ chromatic: {
212
+ // All the examples for ModalLauncher are behavior based, not visual.
213
+ disableSnapshot: true,
214
+ },
215
+ docs: {
216
+ storyDescription: `This is an example in which the modal _cannot_
217
+ be dismissed by clicking in in the backdrop. This is done by
218
+ setting the \`backdropDismissEnabled\` prop on the
219
+ \`<ModalLauncher>\` element to false.`,
220
+ },
221
+ };
222
+
223
+ export const TriggeringProgrammatically: StoryComponentType = () => {
224
+ const [opened, setOpened] = React.useState(false);
225
+
226
+ const handleOpen = () => {
227
+ setOpened(true);
228
+ };
229
+
230
+ const handleClose = () => {
231
+ setOpened(false);
232
+ };
233
+
234
+ return (
235
+ <View>
236
+ <ActionMenu menuText="actions">
237
+ <ActionItem label="Open modal" onClick={handleOpen} />
238
+ </ActionMenu>
239
+
240
+ <ModalLauncher
241
+ onClose={handleClose}
242
+ opened={opened}
243
+ modal={({closeModal}) => (
244
+ <OnePaneDialog
245
+ title="Triggered from action menu"
246
+ content={
247
+ <View>
248
+ <Title>Hello, world</Title>
249
+ </View>
250
+ }
251
+ footer={
252
+ <Button onClick={closeModal}>Close Modal</Button>
253
+ }
254
+ />
255
+ )}
256
+ // Note that this modal launcher has no children.
257
+ />
258
+ </View>
259
+ );
260
+ };
261
+
262
+ TriggeringProgrammatically.parameters = {
263
+ chromatic: {
264
+ // All the examples for ModalLauncher are behavior based, not visual.
265
+ disableSnapshot: true,
266
+ },
267
+ docs: {
268
+ storyDescription: `Sometimes you'll want to trigger a modal
269
+ programmatically. This can be done by rendering \`<ModalLauncher>\`
270
+ without any children and instead setting its \`opened\` prop to
271
+ true. In this situation, \`ModalLauncher\` is a controlled
272
+ component which means you'll also have to update \`opened\` to
273
+ false in response to the \`onClose\` callback being triggered.
274
+ It is necessary to use this method in this example, as
275
+ \`ActionMenu\` cannot have a \`ModalLauncher\` element as a child,
276
+ (it can only have \`Item\` elements as children), so launching a
277
+ modal from a dropdown must be done programatically.`,
278
+ },
279
+ };
280
+
281
+ export const WithClosedFocusId: StoryComponentType = () => {
282
+ const [opened, setOpened] = React.useState(false);
283
+
284
+ const handleOpen = () => {
285
+ setOpened(true);
286
+ };
287
+
288
+ const handleClose = () => {
289
+ setOpened(false);
290
+ };
291
+
292
+ return (
293
+ <View style={{gap: 20}}>
294
+ <Button>Top of page (should not receive focus)</Button>
295
+ <Button id="button-to-focus-on">Focus here after close</Button>
296
+ <ActionMenu menuText="actions">
297
+ <ActionItem label="Open modal" onClick={() => handleOpen()} />
298
+ </ActionMenu>
299
+ <ModalLauncher
300
+ onClose={() => handleClose()}
301
+ opened={opened}
302
+ closedFocusId="button-to-focus-on"
303
+ modal={DefaultModal}
304
+ />
305
+ </View>
306
+ );
307
+ };
308
+
309
+ WithClosedFocusId.parameters = {
310
+ chromatic: {
311
+ // All the examples for ModalLauncher are behavior based, not visual.
312
+ disableSnapshot: true,
313
+ },
314
+ docs: {
315
+ storyDescription: `You can use the \`closedFocusId\` prop on the
316
+ \`ModalLauncher\` to specify where to set the focus after the
317
+ modal has been closed. Imagine the following situation:
318
+ clicking on a dropdown menu option to open a modal
319
+ causes the dropdown to close, and so all of the dropdown options
320
+ are removed from the DOM. This can be a problem because by
321
+ default, the focus shifts to the previously focused element after
322
+ a modal is closed; in this case, the element that opened the modal
323
+ cannot receive focus since it no longer exists in the DOM,
324
+ so when you close the modal, it doesn't know where to focus on the
325
+ page. When the previously focused element no longer exists,
326
+ the focus shifts to the page body, which causes a jump to
327
+ the top of the page. This can make it diffcult to find the original
328
+ dropdown. A solution to this is to use the \`closedFocusId\` prop\
329
+ to specify where to set the focus after the modal has been closed.
330
+ In this example, \`closedFocusId\` is set to the ID of the button
331
+ labeled "Focus here after close." If the focus shifts to the button
332
+ labeled "Top of page (should not receieve focus)," then the focus
333
+ is on the page body, and the \`closedFocusId\` did not work.`,
334
+ },
335
+ };
336
+
337
+ export const WithInitialFocusId: StoryComponentType = () => {
338
+ const [value, setValue] = React.useState("Previously stored value");
339
+ const [value2, setValue2] = React.useState("");
340
+
341
+ const modalInitialFocus = ({closeModal}) => (
342
+ <OnePaneDialog
343
+ title="Single-line title"
344
+ content={
345
+ <>
346
+ <LabeledTextField
347
+ label="Label"
348
+ value={value}
349
+ onChange={setValue}
350
+ />
351
+ <Strut size={Spacing.large_24} />
352
+ <LabeledTextField
353
+ label="Label 2"
354
+ value={value2}
355
+ onChange={setValue2}
356
+ id="text-field-to-be-focused"
357
+ />
358
+ </>
359
+ }
360
+ footer={
361
+ <>
362
+ <Button kind="tertiary" onClick={closeModal}>
363
+ Cancel
364
+ </Button>
365
+ <Strut size={Spacing.medium_16} />
366
+ <Button onClick={closeModal}>Submit</Button>
367
+ </>
368
+ }
369
+ />
370
+ );
371
+
372
+ return (
373
+ <ModalLauncher
374
+ modal={modalInitialFocus}
375
+ initialFocusId="text-field-to-be-focused-field"
376
+ >
377
+ {({openModal}) => (
378
+ <Button onClick={openModal}>
379
+ Open modal with initial focus
380
+ </Button>
381
+ )}
382
+ </ModalLauncher>
383
+ );
384
+ };
385
+
386
+ WithInitialFocusId.parameters = {
387
+ chromatic: {
388
+ // All the examples for ModalLauncher are behavior based, not visual.
389
+ disableSnapshot: true,
390
+ },
391
+ docs: {
392
+ storyDescription: `Sometimes, you may want a specific element inside
393
+ the modal to receive focus first. This can be done using the
394
+ \`initialFocusId\` prop on the \`<ModalLauncher>\` element.
395
+ Just pass in the ID of the element that should receive focus,
396
+ and it will automatically receieve focus once the modal opens.
397
+ In this example, the top text input would have received the focus
398
+ by default, but the bottom text field receives focus instead
399
+ since its ID is passed into the \`initialFocusId\` prop.`,
400
+ },
401
+ };
402
+
403
+ /**
404
+ * Focus trap navigation
405
+ */
406
+ const SubModal = () => (
407
+ <OnePaneDialog
408
+ title="Submodal"
409
+ content={
410
+ <View style={{gap: Spacing.medium_16}}>
411
+ <Body>
412
+ This modal demonstrates how the focus trap works when a
413
+ modal is opened from another modal.
414
+ </Body>
415
+ <Body>
416
+ Try navigating this modal with the keyboard and then close
417
+ it. The focus should be restored to the button that opened
418
+ the modal.
419
+ </Body>
420
+ <LabeledTextField label="Label" value="" onChange={() => {}} />
421
+ <Button>A focusable element</Button>
422
+ </View>
423
+ }
424
+ />
425
+ );
426
+
427
+ export const FocusTrap: StoryComponentType = () => {
428
+ const [selectedValue, setSelectedValue] = React.useState(null);
429
+
430
+ const modalInitialFocus = ({closeModal}) => (
431
+ <OnePaneDialog
432
+ title="Testing the focus trap on multiple modals"
433
+ closeButtonVisible={false}
434
+ content={
435
+ <>
436
+ <Body>
437
+ This modal demonstrates how the focus trap works with
438
+ form elements (or focusable elements). Also demonstrates
439
+ how the focus trap is moved to the next modal when it is
440
+ opened (focus/tap on the `Open another modal` button).
441
+ </Body>
442
+ <Strut size={Spacing.large_24} />
443
+ <RadioGroup
444
+ label="A RadioGroup component inside a modal"
445
+ description="Some description"
446
+ groupName="some-group-name"
447
+ onChange={setSelectedValue}
448
+ selectedValue={selectedValue ?? ""}
449
+ >
450
+ <Choice label="Choice 1" value="some-choice-value" />
451
+ <Choice label="Choice 2" value="some-choice-value-2" />
452
+ </RadioGroup>
453
+ </>
454
+ }
455
+ footer={
456
+ <>
457
+ <ModalLauncher modal={SubModal}>
458
+ {({openModal}) => (
459
+ <Button kind="secondary" onClick={openModal}>
460
+ Open another modal
461
+ </Button>
462
+ )}
463
+ </ModalLauncher>
464
+ <Strut size={Spacing.medium_16} />
465
+ <Button onClick={closeModal} disabled={!selectedValue}>
466
+ Next
467
+ </Button>
468
+ </>
469
+ }
470
+ />
471
+ );
472
+
473
+ return (
474
+ <ModalLauncher modal={modalInitialFocus}>
475
+ {({openModal}) => (
476
+ <Button onClick={openModal}>Open modal with RadioGroup</Button>
477
+ )}
478
+ </ModalLauncher>
479
+ );
480
+ };
481
+
482
+ FocusTrap.storyName = "Navigation with focus trap";
483
+
484
+ FocusTrap.parameters = {
485
+ chromatic: {
486
+ // All the examples for ModalLauncher are behavior based, not visual.
487
+ disableSnapshot: true,
488
+ },
489
+ docs: {
490
+ storyDescription:
491
+ `All modals have a focus trap, which means that the
492
+ focus is locked inside the modal. This is done to prevent the user
493
+ from tabbing out of the modal and losing their place. The focus
494
+ trap is also used to ensure that the focus is restored to the
495
+ correct element when the modal is closed. In this example, the
496
+ focus is trapped inside the modal, and the focus is restored to the
497
+ button that opened the modal when the modal is closed.\n\n` +
498
+ `Also, this example includes a sub-modal that is opened from the
499
+ first modal so we can test how the focus trap works when multiple
500
+ modals are open.`,
501
+ },
502
+ };
503
+
504
+ const styles = StyleSheet.create({
505
+ example: {
506
+ alignItems: "center",
507
+ justifyContent: "center",
508
+ },
509
+ row: {
510
+ flexDirection: "row",
511
+ },
512
+ });