@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.
@@ -6,6 +6,13 @@ import {View} from "@khanacademy/wonder-blocks-core";
6
6
 
7
7
  import type {StyleType} from "@khanacademy/wonder-blocks-core";
8
8
 
9
+ /**
10
+ * List of elements that can be focused
11
+ * @see https://www.w3.org/TR/html5/editing.html#can-be-focused
12
+ */
13
+ const FOCUSABLE_ELEMENTS =
14
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
15
+
9
16
  /**
10
17
  * This component ensures that focus stays within itself. If the user uses Tab
11
18
  * at the end of the modal, or Shift-Tab at the start of the modal, then this
@@ -35,35 +42,11 @@ type Props = {|
35
42
  |};
36
43
 
37
44
  export default class FocusTrap extends React.Component<Props> {
38
- /** The most recent node _inside this component_ to receive focus. */
39
- lastNodeFocusedInModal: ?Node;
40
-
41
- /**
42
- * Whether we're currently applying programmatic focus, and should therefore
43
- * ignore focus change events.
44
- */
45
- ignoreFocusChanges: boolean;
46
-
47
45
  /**
48
46
  * Tabbing is restricted to descendents of this element.
49
47
  */
50
48
  modalRoot: ?Node;
51
49
 
52
- constructor(props: Props) {
53
- super(props);
54
-
55
- this.lastNodeFocusedInModal = null;
56
- this.ignoreFocusChanges = false;
57
- }
58
-
59
- componentDidMount() {
60
- window.addEventListener("focus", this.handleGlobalFocus, true);
61
- }
62
-
63
- componentWillUnmount() {
64
- window.removeEventListener("focus", this.handleGlobalFocus, true);
65
- }
66
-
67
50
  getModalRoot: (node: any) => void = (node) => {
68
51
  if (!node) {
69
52
  // The component is being umounted
@@ -79,98 +62,54 @@ export default class FocusTrap extends React.Component<Props> {
79
62
  this.modalRoot = modalRoot;
80
63
  };
81
64
 
82
- /** Try to focus the given node. Return true iff successful. */
65
+ /**
66
+ * Try to focus the given node. Return true if successful.
67
+ */
83
68
  tryToFocus(node: Node): ?boolean {
84
69
  if (node instanceof HTMLElement) {
85
- this.ignoreFocusChanges = true;
86
70
  try {
87
71
  node.focus();
88
72
  } catch (e) {
89
73
  // ignore error
90
74
  }
91
- this.ignoreFocusChanges = false;
92
75
 
93
76
  return document.activeElement === node;
94
77
  }
95
78
  }
96
79
 
97
80
  /**
98
- * Focus the first focusable descendant of the given node.
81
+ * Focus the next available focusable element within the modal root.
99
82
  *
100
- * Return true if we succeed. Or, if the given node has no focusable
101
- * descendants, return false.
83
+ * @param {boolean} isLast Used to determine the next available item. true =
84
+ * First element within the modal, false = Last element within the modal.
102
85
  */
103
- focusFirstElementIn(currentParent: Node): boolean {
104
- const children = currentParent.childNodes;
105
- for (let i = 0; i < children.length; i++) {
106
- const child = children[i];
107
- if (this.tryToFocus(child) || this.focusFirstElementIn(child)) {
108
- return true;
109
- }
110
- }
111
- return false;
86
+ focusElementIn(isLast: boolean) {
87
+ const modalRootAsHtmlEl = ((this.modalRoot: any): HTMLElement);
88
+ // Get the list of available focusable elements within the modal.
89
+ const focusableNodes = Array.from(
90
+ modalRootAsHtmlEl.querySelectorAll(FOCUSABLE_ELEMENTS),
91
+ );
92
+
93
+ const nodeIndex = !isLast ? focusableNodes.length - 1 : 0;
94
+
95
+ const focusableNode = focusableNodes[nodeIndex];
96
+ this.tryToFocus(focusableNode);
112
97
  }
113
98
 
114
99
  /**
115
- * Focus the last focusable descendant of the given node.
116
- *
117
- * Return true if we succeed. Or, if the given node has no focusable
118
- * descendants, return false.
100
+ * Triggered when the focus is set to the first sentinel. This way, the
101
+ * focus will be redirected to the last element inside the modal dialog.
119
102
  */
120
- focusLastElementIn(currentParent: Node): boolean {
121
- const children = currentParent.childNodes;
122
- for (let i = children.length - 1; i >= 0; i--) {
123
- const child = children[i];
124
- if (this.tryToFocus(child) || this.focusLastElementIn(child)) {
125
- return true;
126
- }
127
- }
128
- return false;
129
- }
130
-
131
- /** This method is called when any node on the page is focused. */
132
- handleGlobalFocus: (e: FocusEvent) => void = (e) => {
133
- // If we're busy applying our own programmatic focus, we ignore focus
134
- // changes, to avoid an infinite loop.
135
- if (this.ignoreFocusChanges) {
136
- return;
137
- }
138
-
139
- const target = e.target;
140
- if (!(target instanceof Node)) {
141
- // Sometimes focus events trigger on the document itself. Ignore!
142
- return;
143
- }
144
-
145
- const modalRoot = this.modalRoot;
146
- if (!modalRoot) {
147
- return;
148
- }
149
-
150
- if (modalRoot.contains(target)) {
151
- // If the newly focused node is inside the modal, we just keep track
152
- // of that.
153
- this.lastNodeFocusedInModal = target;
154
- } else {
155
- // If the newly focused node is outside the modal, we try refocusing
156
- // the first focusable node of the modal. (This could be the user
157
- // pressing Tab on the last node of the modal, or focus escaping in
158
- // some other way.)
159
- this.focusFirstElementIn(modalRoot);
160
-
161
- // But, if it turns out that the first focusable node of the modal
162
- // was what we were previously focusing, then this is probably the
163
- // user pressing Shift-Tab on the first node, wanting to go to the
164
- // end. So, we instead try focusing the last focusable node of the
165
- // modal.
166
- if (document.activeElement === this.lastNodeFocusedInModal) {
167
- this.focusLastElementIn(modalRoot);
168
- }
103
+ handleFocusMoveToLast: () => void = () => {
104
+ this.focusElementIn(false);
105
+ };
169
106
 
170
- // Focus should now be inside the modal, so record the newly-focused
171
- // node as the last node focused in the modal.
172
- this.lastNodeFocusedInModal = document.activeElement;
173
- }
107
+ /**
108
+ * Triggered when the focus is set to the last sentinel. This way, the focus
109
+ * will be redirected to the first element inside the modal dialog.
110
+ */
111
+ handleFocusMoveToFirst: () => void = () => {
112
+ this.focusElementIn(true);
174
113
  };
175
114
 
176
115
  render(): React.Node {
@@ -190,11 +129,21 @@ export default class FocusTrap extends React.Component<Props> {
190
129
  * We set the sentinels to be position: fixed to make sure
191
130
  * they're always in view, this prevents page scrolling when
192
131
  * tabbing. */}
193
- <div tabIndex="0" style={{position: "fixed"}} />
132
+ <div
133
+ tabIndex="0"
134
+ className="modal-focus-trap-first"
135
+ onFocus={this.handleFocusMoveToLast}
136
+ style={{position: "fixed"}}
137
+ />
194
138
  <View style={style} ref={this.getModalRoot}>
195
139
  {this.props.children}
196
140
  </View>
197
- <div tabIndex="0" style={{position: "fixed"}} />
141
+ <div
142
+ tabIndex="0"
143
+ className="modal-focus-trap-last"
144
+ onFocus={this.handleFocusMoveToFirst}
145
+ style={{position: "fixed"}}
146
+ />
198
147
  </React.Fragment>
199
148
  );
200
149
  }
@@ -17,6 +17,14 @@ type Props = {|
17
17
  * If you are creating a custom Dialog, make sure to follow these guidelines:
18
18
  * - Make sure to include it as part of [ModalPanel](/#modalpanel) by using the `footer` prop.
19
19
  * - The footer is completely flexible. Meaning the developer needs to add its own custom layout to match design specs.
20
+ *
21
+ * **Usage**
22
+ *
23
+ * ```js
24
+ * <ModalFooter>
25
+ * <Button onClick={() => {}}>Submit</Button>
26
+ * </ModalFooter>
27
+ * ```
20
28
  */
21
29
  export default class ModalFooter extends React.Component<Props> {
22
30
  static isClassOf(instance: any): boolean {
@@ -114,7 +114,32 @@ type DefaultProps = {|
114
114
  * This is the standard layout for most straightforward modal experiences.
115
115
  *
116
116
  * The ModalHeader is required, but the ModalFooter is optional.
117
- * The content of the dialog itself is fully customizable, but the left/right/top/bottom padding is fixed.
117
+ * The content of the dialog itself is fully customizable, but the
118
+ * left/right/top/bottom padding is fixed.
119
+ *
120
+ * ### Usage
121
+ *
122
+ * ```jsx
123
+ * import {OnePaneDialog} from "@khanacademy/wonder-blocks-modal";
124
+ * import {Body} from "@khanacademy/wonder-blocks-typography";
125
+ *
126
+ * <OnePaneDialog
127
+ * title="Some title"
128
+ * content={
129
+ * <Body>
130
+ * {`Lorem ipsum dolor sit amet, consectetur adipiscing
131
+ * elit, sed do eiusmod tempor incididunt ut labore et
132
+ * dolore magna aliqua. Ut enim ad minim veniam,
133
+ * quis nostrud exercitation ullamco laboris nisi ut
134
+ * aliquip ex ea commodo consequat. Duis aute irure
135
+ * dolor in reprehenderit in voluptate velit esse
136
+ * cillum dolore eu fugiat nulla pariatur. Excepteur
137
+ * sint occaecat cupidatat non proident, sunt in culpa
138
+ * qui officia deserunt mollit anim id est.`}
139
+ * </Body>
140
+ * }
141
+ * />
142
+ * ```
118
143
  */
119
144
  export default class OnePaneDialog extends React.Component<Props> {
120
145
  static defaultProps: DefaultProps = {
@@ -1,248 +0,0 @@
1
- /* eslint-disable no-alert */
2
- // @flow
3
- import * as React from "react";
4
- import {StyleSheet} from "aphrodite";
5
-
6
- import {View} from "@khanacademy/wonder-blocks-core";
7
- import {Body} from "@khanacademy/wonder-blocks-typography";
8
- import Button from "@khanacademy/wonder-blocks-button";
9
- import {ActionMenu, ActionItem} from "@khanacademy/wonder-blocks-dropdown";
10
-
11
- import type {StoryComponentType} from "@storybook/react";
12
- import OnePaneDialog from "./one-pane-dialog.js";
13
- import ModalLauncher from "./modal-launcher.js";
14
-
15
- import type {ModalElement} from "../util/types.js";
16
-
17
- const customViewports = {
18
- phone: {
19
- name: "phone",
20
- styles: {
21
- width: "320px",
22
- height: "568px",
23
- },
24
- },
25
- tablet: {
26
- name: "tablet",
27
- styles: {
28
- width: "640px",
29
- height: "960px",
30
- },
31
- },
32
- desktop: {
33
- name: "desktop",
34
- styles: {
35
- width: "1024px",
36
- height: "768px",
37
- },
38
- },
39
- };
40
-
41
- export default {
42
- title: "Floating/Modal/OnePaneDialog",
43
- parameters: {
44
- viewport: {
45
- viewports: customViewports,
46
- defaultViewport: "desktop",
47
- },
48
- chromatic: {
49
- viewports: [320, 640, 1024],
50
- },
51
- },
52
- };
53
-
54
- export const Simple: StoryComponentType = () => {
55
- const modal = (
56
- <OnePaneDialog
57
- testId="one-pane-dialog-above"
58
- title="Hello, world!"
59
- content={
60
- <View>
61
- <Body>
62
- {
63
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
64
- }
65
- </Body>
66
- </View>
67
- }
68
- closeButtonVisible={true}
69
- />
70
- );
71
-
72
- return (
73
- <ModalLauncher
74
- modal={modal}
75
- testId="modal-launcher-default-example"
76
- opened={true}
77
- onClose={() => alert("This would close the modal.")}
78
- />
79
- );
80
- };
81
-
82
- const styles = StyleSheet.create({
83
- above: {
84
- background: "url(./modal-above.png)",
85
- width: 874,
86
- height: 551,
87
- position: "absolute",
88
- top: 40,
89
- left: -140,
90
- },
91
-
92
- below: {
93
- background: "url(./modal-below.png)",
94
- width: 868,
95
- height: 521,
96
- position: "absolute",
97
- top: -100,
98
- left: -300,
99
- },
100
- });
101
-
102
- export const KitchenSink: StoryComponentType = () => {
103
- const modal = (
104
- <OnePaneDialog
105
- title="Single-line title"
106
- subtitle="Subtitle that provides additional context to the title"
107
- testId="one-pane-dialog-above"
108
- content={
109
- <View>
110
- <Body>
111
- {
112
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
113
- }
114
- </Body>
115
- <br />
116
- <Body>
117
- {
118
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
119
- }
120
- </Body>
121
- <br />
122
- <Body>
123
- {
124
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
125
- }
126
- </Body>
127
- </View>
128
- }
129
- footer={<Button onClick={() => {}}>Button (no-op)</Button>}
130
- closeButtonVisible={true}
131
- above={<View style={styles.above} />}
132
- below={<View style={styles.below} />}
133
- />
134
- );
135
-
136
- return (
137
- <ModalLauncher
138
- modal={modal}
139
- testId="modal-launcher-default-example"
140
- opened={true}
141
- onClose={() => alert("This would close the modal.")}
142
- />
143
- );
144
- };
145
-
146
- export const WithOpener: StoryComponentType = () => {
147
- type MyModalProps = {|
148
- closeModal: () => void,
149
- |};
150
-
151
- const MyModal = ({closeModal}: MyModalProps): ModalElement => (
152
- <OnePaneDialog
153
- title="Single-line title"
154
- subtitle="Subtitle that provides additional context to the title"
155
- testId="one-pane-dialog-above"
156
- content={
157
- <View>
158
- <Body>
159
- {
160
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
161
- }
162
- </Body>
163
- <br />
164
- <Body>
165
- {
166
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
167
- }
168
- </Body>
169
- <br />
170
- <Body>
171
- {
172
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est."
173
- }
174
- </Body>
175
- </View>
176
- }
177
- footer={<Button onClick={() => {}}>Button (no-op)</Button>}
178
- closeButtonVisible={true}
179
- above={<View style={styles.above} />}
180
- below={<View style={styles.below} />}
181
- />
182
- );
183
-
184
- return (
185
- <ModalLauncher
186
- modal={MyModal}
187
- testId="modal-launcher-default-example"
188
- onClose={() => alert("This would close the modal.")}
189
- >
190
- {({openModal}) => <Button onClick={openModal}>Click me</Button>}
191
- </ModalLauncher>
192
- );
193
- };
194
-
195
- WithOpener.parameters = {
196
- viewport: {
197
- defaultViewport: null,
198
- },
199
- chromatic: {
200
- // Don't take screenshots of this story since it would only show a button.
201
- disableSnapshot: true,
202
- },
203
- };
204
-
205
- export const WithClosedFocusId: StoryComponentType = () => {
206
- const [opened, setOpened] = React.useState(false);
207
-
208
- const handleOpen = () => {
209
- setOpened(true);
210
- };
211
-
212
- const handleClose = () => {
213
- setOpened(false);
214
- };
215
-
216
- return (
217
- <View style={{gap: 20}}>
218
- <Button>Top of page (should not receive focus)</Button>
219
- <Button id="button-to-focus-on">Focus here after close</Button>
220
- <ActionMenu menuText="actions">
221
- <ActionItem label="Open modal" onClick={() => handleOpen()} />
222
- </ActionMenu>
223
- <ModalLauncher
224
- onClose={() => handleClose()}
225
- opened={opened}
226
- closedFocusId="button-to-focus-on"
227
- modal={({closeModal}) => (
228
- <OnePaneDialog
229
- title="Triggered from action menu"
230
- content={<View>Hello World</View>}
231
- footer={
232
- <Button onClick={closeModal}>Close Modal</Button>
233
- }
234
- />
235
- )}
236
- />
237
- </View>
238
- );
239
- };
240
-
241
- WithClosedFocusId.parameters = {
242
- chromatic: {
243
- // Don't take screenshots of this story since the case we want
244
- // to test doesn't appear on first render - it occurs after
245
- // we complete a series of steps.
246
- disableSnapshot: true,
247
- },
248
- };