@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,582 @@
1
+ /* eslint-disable no-alert */
2
+ // @flow
3
+ import * as React from "react";
4
+ import {StyleSheet} from "aphrodite";
5
+
6
+ import {
7
+ Breadcrumbs,
8
+ BreadcrumbsItem,
9
+ } from "@khanacademy/wonder-blocks-breadcrumbs";
10
+ import Button from "@khanacademy/wonder-blocks-button";
11
+ import Color from "@khanacademy/wonder-blocks-color";
12
+ import {View} from "@khanacademy/wonder-blocks-core";
13
+ import {Strut} from "@khanacademy/wonder-blocks-layout";
14
+ import Link from "@khanacademy/wonder-blocks-link";
15
+ import {ModalLauncher, OnePaneDialog} from "@khanacademy/wonder-blocks-modal";
16
+ import Spacing from "@khanacademy/wonder-blocks-spacing";
17
+ import {Body, LabelLarge} from "@khanacademy/wonder-blocks-typography";
18
+
19
+ import type {StoryComponentType} from "@storybook/react";
20
+
21
+ import OnePaneDialogArgTypes from "./one-pane-dialog.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
+ export default {
51
+ title: "Modal/OnePaneDialog",
52
+ component: OnePaneDialog,
53
+ decorators: [
54
+ (Story: any): React.Element<typeof View> => (
55
+ <View style={styles.example}>
56
+ <Story />
57
+ </View>
58
+ ),
59
+ ],
60
+ parameters: {
61
+ componentSubtitle: ((
62
+ <ComponentInfo name={name} version={version} />
63
+ ): any),
64
+ docs: {
65
+ description: {
66
+ component: null,
67
+ },
68
+ source: {
69
+ // See https://github.com/storybookjs/storybook/issues/12596
70
+ excludeDecorators: true,
71
+ },
72
+ },
73
+ viewport: {
74
+ viewports: customViewports,
75
+ defaultViewport: "desktop",
76
+ },
77
+ chromatic: {
78
+ viewports: [320, 640, 1024],
79
+ },
80
+ },
81
+ argTypes: OnePaneDialogArgTypes,
82
+ };
83
+
84
+ export const Default: StoryComponentType = (args) => (
85
+ <View style={styles.previewSizer}>
86
+ <View style={styles.modalPositioner}>
87
+ <OnePaneDialog {...args} />
88
+ </View>
89
+ </View>
90
+ );
91
+
92
+ Default.args = {
93
+ content: (
94
+ <Body>
95
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing elit,
96
+ sed do eiusmod tempor incididunt ut labore et dolore magna
97
+ aliqua. Ut enim ad minim veniam, quis nostrud exercitation
98
+ ullamco laboris nisi ut aliquip ex ea commodo consequat.
99
+ Duis aute irure dolor in reprehenderit in voluptate velit
100
+ esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
101
+ occaecat cupidatat non proident, sunt in culpa qui officia
102
+ deserunt mollit anim id est.`}
103
+ </Body>
104
+ ),
105
+ title: "Some title",
106
+ };
107
+
108
+ export const Simple: StoryComponentType = () => (
109
+ <View style={styles.previewSizer}>
110
+ <View style={styles.modalPositioner}>
111
+ <OnePaneDialog
112
+ title="Hello, world! Here is an example of a long title that wraps to the next line."
113
+ content={
114
+ <Body>
115
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing
116
+ elit, sed do eiusmod tempor incididunt ut labore et
117
+ dolore magna aliqua. Ut enim ad minim veniam,
118
+ quis nostrud exercitation ullamco laboris nisi ut
119
+ aliquip ex ea commodo consequat. Duis aute irure
120
+ dolor in reprehenderit in voluptate velit esse
121
+ cillum dolore eu fugiat nulla pariatur. Excepteur
122
+ sint occaecat cupidatat non proident, sunt in culpa
123
+ qui officia deserunt mollit anim id est.`}
124
+ </Body>
125
+ }
126
+ />
127
+ </View>
128
+ </View>
129
+ );
130
+
131
+ Simple.parameters = {
132
+ docs: {
133
+ storyDescription: `This is the most basic OnePaneDialog, with just
134
+ the title and content.`,
135
+ },
136
+ };
137
+
138
+ export const WithFooter: StoryComponentType = () => (
139
+ <View style={styles.previewSizer}>
140
+ <View style={styles.modalPositioner}>
141
+ <OnePaneDialog
142
+ title="Hello, world!"
143
+ content={
144
+ <Body>
145
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing
146
+ elit, sed do eiusmod tempor incididunt ut labore et
147
+ dolore magna aliqua. Ut enim ad minim veniam,
148
+ quis nostrud exercitation ullamco laboris nisi ut
149
+ aliquip ex ea commodo consequat. Duis aute irure
150
+ dolor in reprehenderit in voluptate velit esse
151
+ cillum dolore eu fugiat nulla pariatur. Excepteur
152
+ sint occaecat cupidatat non proident, sunt in culpa
153
+ qui officia deserunt mollit anim id est.`}
154
+ </Body>
155
+ }
156
+ footer={
157
+ <View style={styles.footer}>
158
+ <LabelLarge>Step 1 of 4</LabelLarge>
159
+ <View style={styles.row}>
160
+ <Button kind="tertiary">Previous</Button>
161
+ <Strut size={16} />
162
+ <Button kind="primary">Next</Button>
163
+ </View>
164
+ </View>
165
+ }
166
+ />
167
+ </View>
168
+ </View>
169
+ );
170
+
171
+ WithFooter.parameters = {
172
+ docs: {
173
+ storyDescription: `This OnePaneDialog includes a custom footer.`,
174
+ },
175
+ };
176
+
177
+ export const WithSubtitle: StoryComponentType = () => (
178
+ <View style={styles.previewSizer}>
179
+ <View style={styles.modalPositioner}>
180
+ <OnePaneDialog
181
+ title="Hello, world!"
182
+ content={
183
+ <Body>
184
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing
185
+ elit, sed do eiusmod tempor incididunt ut labore et
186
+ dolore magna aliqua. Ut enim ad minim veniam,
187
+ quis nostrud exercitation ullamco laboris nisi ut
188
+ aliquip ex ea commodo consequat. Duis aute irure
189
+ dolor in reprehenderit in voluptate velit esse
190
+ cillum dolore eu fugiat nulla pariatur. Excepteur
191
+ sint occaecat cupidatat non proident, sunt in culpa
192
+ qui officia deserunt mollit anim id est.`}
193
+ </Body>
194
+ }
195
+ subtitle={
196
+ "Subtitle that provides additional context to the title"
197
+ }
198
+ />
199
+ </View>
200
+ </View>
201
+ );
202
+
203
+ WithSubtitle.parameters = {
204
+ docs: {
205
+ storyDescription: `This OnePaneDialog includes a custom subtitle.`,
206
+ },
207
+ };
208
+
209
+ export const WithBreadcrumbs: StoryComponentType = () => (
210
+ <View style={styles.previewSizer}>
211
+ <View style={styles.modalPositioner}>
212
+ <OnePaneDialog
213
+ title="Hello, world!"
214
+ content={
215
+ <Body>
216
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing
217
+ elit, sed do eiusmod tempor incididunt ut labore et
218
+ dolore magna aliqua. Ut enim ad minim veniam,
219
+ quis nostrud exercitation ullamco laboris nisi ut
220
+ aliquip ex ea commodo consequat. Duis aute irure
221
+ dolor in reprehenderit in voluptate velit esse
222
+ cillum dolore eu fugiat nulla pariatur. Excepteur
223
+ sint occaecat cupidatat non proident, sunt in culpa
224
+ qui officia deserunt mollit anim id est.`}
225
+ </Body>
226
+ }
227
+ breadcrumbs={
228
+ <Breadcrumbs>
229
+ <BreadcrumbsItem>
230
+ <Link href="">Course</Link>
231
+ </BreadcrumbsItem>
232
+ <BreadcrumbsItem>
233
+ <Link href="">Unit</Link>
234
+ </BreadcrumbsItem>
235
+ <BreadcrumbsItem>Lesson</BreadcrumbsItem>
236
+ </Breadcrumbs>
237
+ }
238
+ />
239
+ </View>
240
+ </View>
241
+ );
242
+
243
+ WithBreadcrumbs.parameters = {
244
+ docs: {
245
+ storyDescription: `This OnePaneDialog includes a custom Breadcrumbs
246
+ element.`,
247
+ },
248
+ };
249
+
250
+ export const WithAboveAndBelow: StoryComponentType = () => {
251
+ const aboveStyle = {
252
+ background: "url(./modal-above.png)",
253
+ width: 874,
254
+ height: 551,
255
+ position: "absolute",
256
+ top: 40,
257
+ left: -140,
258
+ };
259
+
260
+ const belowStyle = {
261
+ background: "url(./modal-below.png)",
262
+ width: 868,
263
+ height: 521,
264
+ position: "absolute",
265
+ top: -100,
266
+ left: -300,
267
+ };
268
+
269
+ return (
270
+ <View style={styles.previewSizer}>
271
+ <View style={styles.modalPositioner}>
272
+ <OnePaneDialog
273
+ title="Single-line title"
274
+ content={
275
+ <View>
276
+ <Body>
277
+ {`Lorem ipsum dolor sit amet, consectetur
278
+ adipiscing elit, sed do eiusmod tempor incididunt
279
+ ut labore et dolore magna aliqua. Ut enim ad minim
280
+ veniam, quis nostrud exercitation ullamco laboris
281
+ nisi ut aliquip ex ea commodo consequat. Duis aute
282
+ irure dolor in reprehenderit in voluptate velit
283
+ esse cillum dolore eu fugiat nulla pariatur.
284
+ Excepteur sint occaecat cupidatat non proident,
285
+ sunt in culpa qui officia deserunt mollit anim id
286
+ est.`}
287
+ </Body>
288
+ <br />
289
+ <Body>
290
+ {`Lorem ipsum dolor sit amet, consectetur
291
+ adipiscing elit, sed do eiusmod tempor incididunt
292
+ ut labore et dolore magna aliqua. Ut enim ad minim
293
+ veniam, quis nostrud exercitation ullamco laboris
294
+ nisi ut aliquip ex ea commodo consequat. Duis aute
295
+ irure dolor in reprehenderit in voluptate velit
296
+ esse cillum dolore eu fugiat nulla pariatur.
297
+ Excepteur sint occaecat cupidatat non proident,
298
+ sunt in culpa qui officia deserunt mollit anim id
299
+ est.`}
300
+ </Body>
301
+ <br />
302
+ <Body>
303
+ {`Lorem ipsum dolor sit amet, consectetur
304
+ adipiscing elit, sed do eiusmod tempor incididunt
305
+ ut labore et dolore magna aliqua. Ut enim ad minim
306
+ veniam, quis nostrud exercitation ullamco laboris
307
+ nisi ut aliquip ex ea commodo consequat. Duis aute
308
+ irure dolor in reprehenderit in voluptate velit
309
+ esse cillum dolore eu fugiat nulla pariatur.
310
+ Excepteur sint occaecat cupidatat non proident,
311
+ sunt in culpa qui officia deserunt mollit anim id
312
+ est.`}
313
+ </Body>
314
+ </View>
315
+ }
316
+ above={<View style={aboveStyle} />}
317
+ below={<View style={belowStyle} />}
318
+ />
319
+ </View>
320
+ </View>
321
+ );
322
+ };
323
+
324
+ WithAboveAndBelow.parameters = {
325
+ docs: {
326
+ storyDescription: `The element passed into the \`above\` prop is
327
+ rendered in front of the modal. The element passed into the
328
+ \`below\` prop is rendered behind the modal. In this example,
329
+ a \`<View>\` element with a background image of a person and an
330
+ orange blob is passed into the \`below\` prop. A \`<View>\`
331
+ element with a background image of an arc and a blue semicircle
332
+ is passed into the \`above\` prop. This results in the person's
333
+ head and the orange blob peeking out from behind the modal, and
334
+ the arc and semicircle going over the front of the modal.`,
335
+ },
336
+ };
337
+
338
+ export const WithStyle: StoryComponentType = () => (
339
+ <View style={styles.previewSizer}>
340
+ <View style={styles.modalPositioner}>
341
+ <OnePaneDialog
342
+ title="Hello, world!"
343
+ content={
344
+ <Body>
345
+ {`Lorem ipsum dolor sit amet, consectetur adipiscing
346
+ elit, sed do eiusmod tempor incididunt ut labore et
347
+ dolore magna aliqua. Ut enim ad minim veniam,
348
+ quis nostrud exercitation ullamco laboris nisi ut
349
+ aliquip ex ea commodo consequat. Duis aute irure
350
+ dolor in reprehenderit in voluptate velit esse
351
+ cillum dolore eu fugiat nulla pariatur. Excepteur
352
+ sint occaecat cupidatat non proident, sunt in culpa
353
+ qui officia deserunt mollit anim id est.`}
354
+ </Body>
355
+ }
356
+ style={{
357
+ color: Color.blue,
358
+ maxWidth: 1000,
359
+ }}
360
+ />
361
+ </View>
362
+ </View>
363
+ );
364
+
365
+ WithStyle.parameters = {
366
+ docs: {
367
+ storyDescription: `A OnePaneDialog can have custom styles via the
368
+ \`style\` prop. Here, the modal has a \`maxWidth: 1000\` and
369
+ \`color: Color.blue\` in its custom styles.`,
370
+ },
371
+ };
372
+
373
+ export const FlexibleModal: StoryComponentType = () => {
374
+ const styles = StyleSheet.create({
375
+ example: {
376
+ padding: Spacing.xLarge_32,
377
+ alignItems: "center",
378
+ },
379
+ row: {
380
+ flexDirection: "row",
381
+ justifyContent: "flex-end",
382
+ },
383
+ footer: {
384
+ alignItems: "center",
385
+ flexDirection: "row",
386
+ justifyContent: "space-between",
387
+ width: "100%",
388
+ },
389
+ });
390
+
391
+ type ExerciseModalProps = {|
392
+ current: number,
393
+ handleNextButton: () => mixed,
394
+ handlePrevButton: () => mixed,
395
+ question: string,
396
+ total: number,
397
+ |};
398
+
399
+ function ExerciseModal(props: ExerciseModalProps) {
400
+ const {current, handleNextButton, handlePrevButton, question, total} =
401
+ props;
402
+
403
+ return (
404
+ <OnePaneDialog
405
+ title="Exercises"
406
+ content={
407
+ <View>
408
+ <Body>This is the current question: {question}</Body>
409
+ </View>
410
+ }
411
+ footer={
412
+ <View style={styles.footer}>
413
+ <LabelLarge>
414
+ Step {current + 1} of {total}
415
+ </LabelLarge>
416
+ <View style={styles.row}>
417
+ <Button kind="tertiary" onClick={handlePrevButton}>
418
+ Previous
419
+ </Button>
420
+ <Strut size={16} />
421
+ <Button kind="primary" onClick={handleNextButton}>
422
+ Next
423
+ </Button>
424
+ </View>
425
+ </View>
426
+ }
427
+ />
428
+ );
429
+ }
430
+
431
+ type ExerciseContainerProps = {|
432
+ questions: Array<string>,
433
+ |};
434
+
435
+ function ExerciseContainer(props: ExerciseContainerProps) {
436
+ const [currentQuestion, setCurrentQuestion] = React.useState(0);
437
+
438
+ const handleNextButton = () => {
439
+ setCurrentQuestion(
440
+ Math.min(currentQuestion + 1, props.questions.length - 1),
441
+ );
442
+ };
443
+
444
+ const handlePrevButton = () => {
445
+ setCurrentQuestion(Math.max(0, currentQuestion - 1));
446
+ };
447
+
448
+ return (
449
+ <ModalLauncher
450
+ modal={
451
+ <ExerciseModal
452
+ question={props.questions[currentQuestion]}
453
+ current={currentQuestion}
454
+ total={props.questions.length}
455
+ handlePrevButton={handlePrevButton}
456
+ handleNextButton={handleNextButton}
457
+ />
458
+ }
459
+ >
460
+ {({openModal}) => (
461
+ <Button onClick={openModal}>Open flexible modal</Button>
462
+ )}
463
+ </ModalLauncher>
464
+ );
465
+ }
466
+
467
+ return (
468
+ <View style={styles.example}>
469
+ <ExerciseContainer
470
+ questions={[
471
+ "First question",
472
+ "Second question",
473
+ "Last question",
474
+ ]}
475
+ />
476
+ </View>
477
+ );
478
+ };
479
+
480
+ FlexibleModal.parameters = {
481
+ chromatic: {
482
+ // This example is behavior based, not visual.
483
+ disableSnapshot: true,
484
+ },
485
+ docs: {
486
+ storyDescription: `This example illustrates how we can update the
487
+ Modal's contents by wrapping it into a new component/container.
488
+ \`Modal\` is built in a way that provides great flexibility and
489
+ makes it work with different variations and/or layouts.`,
490
+ },
491
+ };
492
+
493
+ export const WithLauncher: StoryComponentType = () => {
494
+ type MyModalProps = {|
495
+ closeModal: () => void,
496
+ |};
497
+
498
+ const MyModal = ({
499
+ closeModal,
500
+ }: MyModalProps): React.Element<typeof OnePaneDialog> => (
501
+ <OnePaneDialog
502
+ title="Single-line title"
503
+ content={
504
+ <Body>
505
+ {`Lorem ipsum dolor sit amet, consectetur
506
+ adipiscing elit, sed do eiusmod tempor incididunt
507
+ ut labore et dolore magna aliqua. Ut enim ad minim
508
+ veniam, quis nostrud exercitation ullamco laboris
509
+ nisi ut aliquip ex ea commodo consequat. Duis aute
510
+ irure dolor in reprehenderit in voluptate velit
511
+ esse cillum dolore eu fugiat nulla pariatur.
512
+ Excepteur sint occaecat cupidatat non proident,
513
+ sunt in culpa qui officia deserunt mollit anim id
514
+ est.`}
515
+ </Body>
516
+ }
517
+ footer={<Button onClick={closeModal}>Close</Button>}
518
+ />
519
+ );
520
+
521
+ return (
522
+ <ModalLauncher modal={MyModal}>
523
+ {({openModal}) => (
524
+ <Button onClick={openModal}>Click me to open the modal</Button>
525
+ )}
526
+ </ModalLauncher>
527
+ );
528
+ };
529
+
530
+ WithLauncher.parameters = {
531
+ chromatic: {
532
+ // Don't take screenshots of this story since it would only show a
533
+ // button and not the actual modal.
534
+ disableSnapshot: true,
535
+ },
536
+ docs: {
537
+ storyDescription: `A modal can be launched using a launcher. Here,
538
+ the launcher is a \`<Button>\` element whose \`onClick\` function
539
+ opens the modal. The modal passed into the \`modal\` prop of
540
+ the \`<ModalLauncher>\` element is a \`<OnePaneDialog>\`.
541
+ To turn an element into a launcher, wrap the element in a
542
+ \`<ModalLauncher>\` element.`,
543
+ },
544
+ };
545
+
546
+ const styles = StyleSheet.create({
547
+ example: {
548
+ alignItems: "center",
549
+ justifyContent: "center",
550
+ },
551
+ modalPositioner: {
552
+ // Checkerboard background
553
+ backgroundImage:
554
+ "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
555
+ backgroundSize: "20px 20px",
556
+ backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
557
+
558
+ flexDirection: "row",
559
+ alignItems: "center",
560
+ justifyContent: "center",
561
+
562
+ position: "absolute",
563
+ left: 0,
564
+ right: 0,
565
+ top: 0,
566
+ bottom: 0,
567
+ },
568
+ previewSizer: {
569
+ minHeight: 600,
570
+ width: "100%",
571
+ },
572
+ row: {
573
+ flexDirection: "row",
574
+ justifyContent: "flex-end",
575
+ },
576
+ footer: {
577
+ alignItems: "center",
578
+ flexDirection: "row",
579
+ justifyContent: "space-between",
580
+ width: "100%",
581
+ },
582
+ });
@@ -0,0 +1,101 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {render, screen} from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import Button from "@khanacademy/wonder-blocks-button";
7
+ import {Choice, RadioGroup} from "@khanacademy/wonder-blocks-form";
8
+
9
+ import FocusTrap from "../focus-trap.js";
10
+
11
+ describe("FocusTrap", () => {
12
+ it("Focus should move to the first focusable element", () => {
13
+ // Arrange
14
+ render(
15
+ <>
16
+ <FocusTrap>
17
+ <RadioGroup
18
+ label="some-label"
19
+ description="some-description"
20
+ groupName="some-group-name"
21
+ onChange={() => {}}
22
+ selectedValue={""}
23
+ >
24
+ <Choice
25
+ label="first option"
26
+ value="some-choice-value"
27
+ />
28
+ <Choice
29
+ label="second option"
30
+ value="some-choice-value-2"
31
+ description="Some choice description."
32
+ />
33
+ </RadioGroup>
34
+ <Button>A button</Button>
35
+ </FocusTrap>
36
+ <Button>An external button</Button>
37
+ </>,
38
+ );
39
+
40
+ // Initial focused element
41
+ const firstRadioButton = screen.getByRole("radio", {
42
+ name: /first option/i,
43
+ });
44
+ firstRadioButton.focus();
45
+
46
+ // Act
47
+ // focus on the button
48
+ userEvent.tab();
49
+ // focus on the last sentinel
50
+ userEvent.tab();
51
+
52
+ // Assert
53
+ // Redirect focus to the first radiobutton.
54
+ expect(firstRadioButton).toHaveFocus();
55
+ });
56
+
57
+ it("Focus should move to the last focusable element", () => {
58
+ // Arrange
59
+ render(
60
+ <>
61
+ <FocusTrap>
62
+ <RadioGroup
63
+ label="some-label"
64
+ description="some-description"
65
+ groupName="some-group-name"
66
+ onChange={() => {}}
67
+ selectedValue={""}
68
+ >
69
+ <Choice
70
+ label="first option"
71
+ value="some-choice-value"
72
+ />
73
+ <Choice
74
+ label="second option"
75
+ value="some-choice-value-2"
76
+ description="Some choice description."
77
+ />
78
+ </RadioGroup>
79
+ <Button>A focusable button</Button>
80
+ </FocusTrap>
81
+ <Button>An external button</Button>
82
+ </>,
83
+ );
84
+
85
+ // Initial focused element
86
+ const firstRadioButton = screen.getByRole("radio", {
87
+ name: /first option/i,
88
+ });
89
+ firstRadioButton.focus();
90
+
91
+ // Act
92
+ // focus on the first sentinel
93
+ userEvent.tab({shift: true});
94
+
95
+ // Assert
96
+ // Redirect focus to the button.
97
+ expect(
98
+ screen.getByRole("button", {name: "A focusable button"}),
99
+ ).toHaveFocus();
100
+ });
101
+ });