@khanacademy/wonder-blocks-modal 5.1.11 → 5.1.13
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.
- package/CHANGELOG.md +22 -0
- package/package.json +10 -10
- package/src/components/__tests__/close-button.test.tsx +0 -37
- package/src/components/__tests__/focus-trap.test.tsx +0 -100
- package/src/components/__tests__/modal-backdrop.test.tsx +0 -241
- package/src/components/__tests__/modal-dialog.test.tsx +0 -87
- package/src/components/__tests__/modal-header.test.tsx +0 -97
- package/src/components/__tests__/modal-launcher.test.tsx +0 -436
- package/src/components/__tests__/modal-panel.test.tsx +0 -42
- package/src/components/__tests__/one-pane-dialog.test.tsx +0 -87
- package/src/components/close-button.tsx +0 -64
- package/src/components/focus-trap.tsx +0 -148
- package/src/components/modal-backdrop.tsx +0 -172
- package/src/components/modal-content.tsx +0 -81
- package/src/components/modal-context.ts +0 -16
- package/src/components/modal-dialog.tsx +0 -164
- package/src/components/modal-footer.tsx +0 -54
- package/src/components/modal-header.tsx +0 -194
- package/src/components/modal-launcher.tsx +0 -297
- package/src/components/modal-panel.tsx +0 -188
- package/src/components/one-pane-dialog.tsx +0 -244
- package/src/components/scroll-disabler.ts +0 -95
- package/src/index.ts +0 -17
- package/src/themes/default.ts +0 -36
- package/src/themes/khanmigo.ts +0 -16
- package/src/themes/themed-modal-dialog.tsx +0 -44
- package/src/util/constants.ts +0 -6
- package/src/util/find-focusable-nodes.ts +0 -12
- package/src/util/maybe-get-portal-mounted-modal-host-element.test.tsx +0 -133
- package/src/util/maybe-get-portal-mounted-modal-host-element.ts +0 -35
- package/src/util/types.ts +0 -13
- package/tsconfig-build.json +0 -20
- package/tsconfig-build.tsbuildinfo +0 -1
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import {Breadcrumbs} from "@khanacademy/wonder-blocks-breadcrumbs";
|
|
3
|
-
import {View} from "@khanacademy/wonder-blocks-core";
|
|
4
|
-
import {HeadingMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
|
|
5
|
-
import {
|
|
6
|
-
ThemedStylesFn,
|
|
7
|
-
useScopedTheme,
|
|
8
|
-
useStyles,
|
|
9
|
-
} from "@khanacademy/wonder-blocks-theming";
|
|
10
|
-
import {
|
|
11
|
-
ModalDialogThemeContext,
|
|
12
|
-
ModalDialogThemeContract,
|
|
13
|
-
} from "../themes/themed-modal-dialog";
|
|
14
|
-
|
|
15
|
-
type Common = {
|
|
16
|
-
/**
|
|
17
|
-
* The main title rendered in larger bold text.
|
|
18
|
-
*/
|
|
19
|
-
title: string;
|
|
20
|
-
/**
|
|
21
|
-
* Whether to display the "light" version of this component instead, for
|
|
22
|
-
* use when the item is used on a dark background.
|
|
23
|
-
*/
|
|
24
|
-
light: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* An id to provide a selector for the title element.
|
|
27
|
-
*/
|
|
28
|
-
titleId: string;
|
|
29
|
-
/**
|
|
30
|
-
* Test ID used for e2e testing.
|
|
31
|
-
*
|
|
32
|
-
* In this case, this component is internal, so `testId` is composed with
|
|
33
|
-
* the `testId` passed down from the Dialog variant + a suffix to scope it
|
|
34
|
-
* to this component.
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* For testId="some-random-id"
|
|
38
|
-
* The result will be: `some-random-id-modal-header`
|
|
39
|
-
*/
|
|
40
|
-
testId?: string;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
type WithSubtitle = Common & {
|
|
44
|
-
/**
|
|
45
|
-
* The dialog subtitle.
|
|
46
|
-
*/
|
|
47
|
-
subtitle: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
type WithBreadcrumbs = Common & {
|
|
51
|
-
/**
|
|
52
|
-
* Adds a breadcrumb-trail, appearing in the ModalHeader, above the title.
|
|
53
|
-
*/
|
|
54
|
-
breadcrumbs: React.ReactElement<React.ComponentProps<typeof Breadcrumbs>>;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type Props = Common | WithSubtitle | WithBreadcrumbs;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* This is a helper component that is never rendered by itself. It is always
|
|
61
|
-
* pinned to the top of the dialog, is responsive using the same behavior as its
|
|
62
|
-
* parent dialog, and has the following properties:
|
|
63
|
-
* - title
|
|
64
|
-
* - breadcrumb OR subtitle, but not both.
|
|
65
|
-
*
|
|
66
|
-
* **Accessibility notes:**
|
|
67
|
-
*
|
|
68
|
-
* - By default (e.g. using [OnePaneDialog](/#onepanedialog)), `titleId` is
|
|
69
|
-
* populated automatically by the parent container.
|
|
70
|
-
* - If there is a custom Dialog implementation (e.g. `TwoPaneDialog`), the
|
|
71
|
-
* ModalHeader doesn’t have to have the `titleId` prop however this is
|
|
72
|
-
* recommended. It should match the `aria-labelledby` prop of the
|
|
73
|
-
* [ModalDialog](/#modaldialog) component. If you want to see an example of
|
|
74
|
-
* how to generate this ID, check [IDProvider](/#idprovider).
|
|
75
|
-
*
|
|
76
|
-
* **Implementation notes:**
|
|
77
|
-
*
|
|
78
|
-
* If you are creating a custom Dialog, make sure to follow these guidelines:
|
|
79
|
-
* - Make sure to include it as part of [ModalPanel](/#modalpanel) by using the
|
|
80
|
-
* `header` prop.
|
|
81
|
-
* - Add a title (required).
|
|
82
|
-
* - Optionally add a subtitle or breadcrumbs.
|
|
83
|
-
* - We encourage you to add `titleId` (see Accessibility notes).
|
|
84
|
-
* - If the `ModalPanel` has a dark background, make sure to set `light` to
|
|
85
|
-
* `false`.
|
|
86
|
-
* - If you need to create e2e tests, make sure to pass a `testId` prop and
|
|
87
|
-
* add a sufix to scope the testId to this component: e.g.
|
|
88
|
-
* `some-random-id-ModalHeader`. This scope will also be passed to the title
|
|
89
|
-
* and subtitle elements: e.g. `some-random-id-ModalHeader-title`.
|
|
90
|
-
*
|
|
91
|
-
* Example:
|
|
92
|
-
*
|
|
93
|
-
* ```js
|
|
94
|
-
* <ModalHeader
|
|
95
|
-
* title="Sidebar using ModalHeader"
|
|
96
|
-
* subtitle="subtitle"
|
|
97
|
-
* titleId="uniqueTitleId"
|
|
98
|
-
* light={false}
|
|
99
|
-
* />
|
|
100
|
-
* ```
|
|
101
|
-
*/
|
|
102
|
-
export default function ModalHeader(props: Props) {
|
|
103
|
-
const {
|
|
104
|
-
// @ts-expect-error [FEI-5019] - TS2339 - Property 'breadcrumbs' does not exist on type 'Readonly<Props> & Readonly<{ children?: ReactNode; }>'.
|
|
105
|
-
breadcrumbs = undefined,
|
|
106
|
-
light,
|
|
107
|
-
// @ts-expect-error [FEI-5019] - TS2339 - Property 'subtitle' does not exist on type 'Readonly<Props> & Readonly<{ children?: ReactNode; }>'.
|
|
108
|
-
subtitle = undefined,
|
|
109
|
-
testId,
|
|
110
|
-
title,
|
|
111
|
-
titleId,
|
|
112
|
-
} = props;
|
|
113
|
-
|
|
114
|
-
if (subtitle && breadcrumbs) {
|
|
115
|
-
throw new Error("'subtitle' and 'breadcrumbs' can't be used together");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const {theme} = useScopedTheme(ModalDialogThemeContext);
|
|
119
|
-
const styles = useStyles(themedStylesFn, theme);
|
|
120
|
-
|
|
121
|
-
return (
|
|
122
|
-
<View style={[styles.header, !light && styles.dark]} testId={testId}>
|
|
123
|
-
{breadcrumbs && (
|
|
124
|
-
<View style={styles.breadcrumbs}>{breadcrumbs}</View>
|
|
125
|
-
)}
|
|
126
|
-
<HeadingMedium
|
|
127
|
-
style={styles.title}
|
|
128
|
-
id={titleId}
|
|
129
|
-
testId={testId && `${testId}-title`}
|
|
130
|
-
>
|
|
131
|
-
{title}
|
|
132
|
-
</HeadingMedium>
|
|
133
|
-
{subtitle && (
|
|
134
|
-
<LabelSmall
|
|
135
|
-
style={light && styles.subtitle}
|
|
136
|
-
testId={testId && `${testId}-subtitle`}
|
|
137
|
-
>
|
|
138
|
-
{subtitle}
|
|
139
|
-
</LabelSmall>
|
|
140
|
-
)}
|
|
141
|
-
</View>
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Media query for small screens.
|
|
147
|
-
* TODO(WB-1655): Change this to use the theme instead (inside themedStylesFn).
|
|
148
|
-
* e.g. `[theme.breakpoints.small]: {...}`
|
|
149
|
-
*/
|
|
150
|
-
const small = "@media (max-width: 767px)";
|
|
151
|
-
|
|
152
|
-
const themedStylesFn: ThemedStylesFn<ModalDialogThemeContract> = (theme) => ({
|
|
153
|
-
header: {
|
|
154
|
-
boxShadow: `0px 1px 0px ${theme.color.shadow.default}`,
|
|
155
|
-
display: "flex",
|
|
156
|
-
flexDirection: "column",
|
|
157
|
-
minHeight: 66,
|
|
158
|
-
padding: `${theme.spacing.header.medium}px ${theme.spacing.header.large}px`,
|
|
159
|
-
position: "relative",
|
|
160
|
-
width: "100%",
|
|
161
|
-
|
|
162
|
-
[small]: {
|
|
163
|
-
paddingLeft: theme.spacing.header.small,
|
|
164
|
-
paddingRight: theme.spacing.header.small,
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
|
|
168
|
-
dark: {
|
|
169
|
-
background: theme.color.bg.inverse,
|
|
170
|
-
color: theme.color.text.inverse,
|
|
171
|
-
},
|
|
172
|
-
|
|
173
|
-
breadcrumbs: {
|
|
174
|
-
color: theme.color.text.secondary,
|
|
175
|
-
marginBottom: theme.spacing.header.xsmall,
|
|
176
|
-
},
|
|
177
|
-
|
|
178
|
-
title: {
|
|
179
|
-
// Prevent title from overlapping the close button
|
|
180
|
-
paddingRight: theme.spacing.header.small,
|
|
181
|
-
[small]: {
|
|
182
|
-
paddingRight: theme.spacing.header.large,
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
subtitle: {
|
|
187
|
-
color: theme.color.text.secondary,
|
|
188
|
-
marginTop: theme.spacing.header.xsmall,
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
ModalHeader.defaultProps = {
|
|
193
|
-
light: true,
|
|
194
|
-
};
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import * as ReactDOM from "react-dom";
|
|
3
|
-
import {StyleSheet} from "aphrodite";
|
|
4
|
-
|
|
5
|
-
import {withActionScheduler} from "@khanacademy/wonder-blocks-timing";
|
|
6
|
-
import type {WithActionSchedulerProps} from "@khanacademy/wonder-blocks-timing";
|
|
7
|
-
|
|
8
|
-
import FocusTrap from "./focus-trap";
|
|
9
|
-
import ModalBackdrop from "./modal-backdrop";
|
|
10
|
-
import ScrollDisabler from "./scroll-disabler";
|
|
11
|
-
import type {ModalElement} from "../util/types";
|
|
12
|
-
import ModalContext from "./modal-context";
|
|
13
|
-
|
|
14
|
-
type Props = Readonly<{
|
|
15
|
-
/**
|
|
16
|
-
* The modal to render.
|
|
17
|
-
*
|
|
18
|
-
* The modal will be rendered inside of a container whose parent is
|
|
19
|
-
* document.body. This allows us to use ModalLauncher within menus and
|
|
20
|
-
* other components that clip their content. If the modal needs to close
|
|
21
|
-
* itself by some other means than tapping the backdrop or the default
|
|
22
|
-
* close button a render callback can be passed. The closeModal function
|
|
23
|
-
* provided to this callback can be called to close the modal.
|
|
24
|
-
*
|
|
25
|
-
* Note: Don't call `closeModal` while rendering! It should be used to
|
|
26
|
-
* respond to user intearction, like `onClick`.
|
|
27
|
-
*/
|
|
28
|
-
modal: ModalElement | ((props: {closeModal: () => void}) => ModalElement);
|
|
29
|
-
/**
|
|
30
|
-
* Enables the backdrop to dismiss the modal on click/tap
|
|
31
|
-
*/
|
|
32
|
-
backdropDismissEnabled?: boolean;
|
|
33
|
-
/**
|
|
34
|
-
* The selector for the element that will be focused when the dialog shows.
|
|
35
|
-
* When not set, the first tabbable element within the dialog will be used.
|
|
36
|
-
*/
|
|
37
|
-
initialFocusId?: string;
|
|
38
|
-
/**
|
|
39
|
-
* The selector for the element that will be focused after the dialog
|
|
40
|
-
* closes. When not set, the last element focused outside the modal will
|
|
41
|
-
* be used if it exists.
|
|
42
|
-
*/
|
|
43
|
-
closedFocusId?: string;
|
|
44
|
-
/**
|
|
45
|
-
* Test ID used for e2e testing. It's set on the ModalBackdrop
|
|
46
|
-
*/
|
|
47
|
-
testId?: string;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Renders the modal when true, renders nothing when false.
|
|
51
|
-
*
|
|
52
|
-
* Using this prop makes the component behave as a controlled component.
|
|
53
|
-
* The parent is responsible for managing the opening/closing of the modal
|
|
54
|
-
* when using this prop. `onClose` should always be used and `children`
|
|
55
|
-
* should never be used with this prop. Not doing so will result in an
|
|
56
|
-
* error being thrown.
|
|
57
|
-
*/
|
|
58
|
-
opened?: boolean;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* If the parent needs to be notified when the modal is closed, use this
|
|
62
|
-
* prop. You probably want to use this instead of `onClose` on the modals
|
|
63
|
-
* themselves, since this will capture a more complete set of close events.
|
|
64
|
-
*
|
|
65
|
-
* Called when the modal needs to notify the parent component that it should
|
|
66
|
-
* be closed.
|
|
67
|
-
*
|
|
68
|
-
* This prop must be used when the component is being used as a controlled
|
|
69
|
-
* component.
|
|
70
|
-
*/
|
|
71
|
-
onClose?: () => unknown;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* WARNING: This props should only be used when using the component as a
|
|
75
|
-
* controlled component.
|
|
76
|
-
*/
|
|
77
|
-
children?: (arg1: {openModal: () => unknown}) => React.ReactNode;
|
|
78
|
-
}> &
|
|
79
|
-
WithActionSchedulerProps;
|
|
80
|
-
|
|
81
|
-
type DefaultProps = Readonly<{
|
|
82
|
-
backdropDismissEnabled: Props["backdropDismissEnabled"];
|
|
83
|
-
}>;
|
|
84
|
-
|
|
85
|
-
type State = Readonly<{
|
|
86
|
-
/** Whether the modal should currently be open. */
|
|
87
|
-
opened: boolean;
|
|
88
|
-
}>;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* This component enables you to launch a modal, covering the screen.
|
|
92
|
-
*
|
|
93
|
-
* Children have access to `openModal` function via the function-as-children
|
|
94
|
-
* pattern, so one common use case is for this component to wrap a button:
|
|
95
|
-
*
|
|
96
|
-
* ```js
|
|
97
|
-
* <ModalLauncher modal={<TwoColumnModal ... />}>
|
|
98
|
-
* {({openModal}) => <button onClick={openModal}>Learn more</button>}
|
|
99
|
-
* </ModalLauncher>
|
|
100
|
-
* ```
|
|
101
|
-
*
|
|
102
|
-
* The actual modal itself is constructed separately, using a layout component
|
|
103
|
-
* like OnePaneDialog and is provided via
|
|
104
|
-
* the `modal` prop.
|
|
105
|
-
*/
|
|
106
|
-
class ModalLauncher extends React.Component<Props, State> {
|
|
107
|
-
/**
|
|
108
|
-
* The most recent element _outside this component_ that received focus.
|
|
109
|
-
* Be default, it captures the element that triggered the modal opening
|
|
110
|
-
*/
|
|
111
|
-
lastElementFocusedOutsideModal: HTMLElement | null | undefined;
|
|
112
|
-
|
|
113
|
-
static defaultProps: DefaultProps = {
|
|
114
|
-
backdropDismissEnabled: true,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
static getDerivedStateFromProps(
|
|
118
|
-
props: Props,
|
|
119
|
-
state: State,
|
|
120
|
-
): Partial<State> {
|
|
121
|
-
if (typeof props.opened === "boolean" && props.children) {
|
|
122
|
-
// eslint-disable-next-line no-console
|
|
123
|
-
console.warn("'children' and 'opened' can't be used together");
|
|
124
|
-
}
|
|
125
|
-
if (typeof props.opened === "boolean" && !props.onClose) {
|
|
126
|
-
// eslint-disable-next-line no-console
|
|
127
|
-
console.warn("'onClose' should be used with 'opened'");
|
|
128
|
-
}
|
|
129
|
-
if (typeof props.opened !== "boolean" && !props.children) {
|
|
130
|
-
// eslint-disable-next-line no-console
|
|
131
|
-
console.warn("either 'children' or 'opened' must be set");
|
|
132
|
-
}
|
|
133
|
-
return {
|
|
134
|
-
opened:
|
|
135
|
-
typeof props.opened === "boolean" ? props.opened : state.opened,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
state: State = {opened: false};
|
|
140
|
-
|
|
141
|
-
componentDidUpdate(prevProps: Props) {
|
|
142
|
-
// ensures the element is stored only when the modal is opened
|
|
143
|
-
if (!prevProps.opened && this.props.opened) {
|
|
144
|
-
this._saveLastElementFocused();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
_saveLastElementFocused: () => void = () => {
|
|
149
|
-
// keep a reference of the element that triggers the modal
|
|
150
|
-
// @ts-expect-error [FEI-5019] - TS2322 - Type 'Element | null' is not assignable to type 'HTMLElement | null | undefined'.
|
|
151
|
-
this.lastElementFocusedOutsideModal = document.activeElement;
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
_openModal: () => void = () => {
|
|
155
|
-
this._saveLastElementFocused();
|
|
156
|
-
this.setState({opened: true});
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
_returnFocus: () => void = () => {
|
|
160
|
-
const {closedFocusId, schedule} = this.props;
|
|
161
|
-
const lastElement = this.lastElementFocusedOutsideModal;
|
|
162
|
-
|
|
163
|
-
// Focus on the specified element after closing the modal.
|
|
164
|
-
if (closedFocusId) {
|
|
165
|
-
const focusElement = ReactDOM.findDOMNode(
|
|
166
|
-
document.getElementById(closedFocusId),
|
|
167
|
-
) as any;
|
|
168
|
-
|
|
169
|
-
if (focusElement) {
|
|
170
|
-
// Wait for the modal to leave the DOM before trying
|
|
171
|
-
// to focus on the specified element.
|
|
172
|
-
schedule.animationFrame(() => {
|
|
173
|
-
focusElement.focus();
|
|
174
|
-
});
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (lastElement != null) {
|
|
180
|
-
// Wait for the modal to leave the DOM before trying to
|
|
181
|
-
// return focus to the element that triggered the modal.
|
|
182
|
-
schedule.animationFrame(() => {
|
|
183
|
-
lastElement.focus();
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
handleCloseModal: () => void = () => {
|
|
189
|
-
this.setState({opened: false}, () => {
|
|
190
|
-
const {onClose} = this.props;
|
|
191
|
-
|
|
192
|
-
onClose?.();
|
|
193
|
-
this._returnFocus();
|
|
194
|
-
});
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
_renderModal(): ModalElement {
|
|
198
|
-
if (typeof this.props.modal === "function") {
|
|
199
|
-
return this.props.modal({
|
|
200
|
-
closeModal: this.handleCloseModal,
|
|
201
|
-
});
|
|
202
|
-
} else {
|
|
203
|
-
return this.props.modal;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
render(): React.ReactElement | null {
|
|
208
|
-
const renderedChildren = this.props.children
|
|
209
|
-
? this.props.children({
|
|
210
|
-
openModal: this._openModal,
|
|
211
|
-
})
|
|
212
|
-
: null;
|
|
213
|
-
|
|
214
|
-
const {body} = document;
|
|
215
|
-
if (!body) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<ModalContext.Provider value={{closeModal: this.handleCloseModal}}>
|
|
221
|
-
{renderedChildren}
|
|
222
|
-
{this.state.opened &&
|
|
223
|
-
ReactDOM.createPortal(
|
|
224
|
-
/* We need the container View that FocusTrap creates to be at the
|
|
225
|
-
correct z-index so that it'll be above the global nav in webapp. */
|
|
226
|
-
<FocusTrap style={styles.container}>
|
|
227
|
-
<ModalBackdrop
|
|
228
|
-
initialFocusId={this.props.initialFocusId}
|
|
229
|
-
testId={this.props.testId}
|
|
230
|
-
onCloseModal={
|
|
231
|
-
this.props.backdropDismissEnabled
|
|
232
|
-
? this.handleCloseModal
|
|
233
|
-
: () => {}
|
|
234
|
-
}
|
|
235
|
-
>
|
|
236
|
-
{this._renderModal()}
|
|
237
|
-
</ModalBackdrop>
|
|
238
|
-
</FocusTrap>,
|
|
239
|
-
body,
|
|
240
|
-
)}
|
|
241
|
-
{this.state.opened && (
|
|
242
|
-
<ModalLauncherKeypressListener
|
|
243
|
-
onClose={this.handleCloseModal}
|
|
244
|
-
/>
|
|
245
|
-
)}
|
|
246
|
-
{this.state.opened && <ScrollDisabler />}
|
|
247
|
-
</ModalContext.Provider>
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** A component that, when mounted, calls `onClose` when Escape is pressed. */
|
|
253
|
-
class ModalLauncherKeypressListener extends React.Component<{
|
|
254
|
-
onClose: () => unknown;
|
|
255
|
-
}> {
|
|
256
|
-
componentDidMount() {
|
|
257
|
-
window.addEventListener("keyup", this._handleKeyup);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
componentWillUnmount() {
|
|
261
|
-
window.removeEventListener("keyup", this._handleKeyup);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
_handleKeyup = (e: KeyboardEvent) => {
|
|
265
|
-
// We check the key as that's keyboard layout agnostic and also avoids
|
|
266
|
-
// the minefield of deprecated number type properties like keyCode and
|
|
267
|
-
// which, with the replacement code, which uses a string instead.
|
|
268
|
-
if (e.key === "Escape") {
|
|
269
|
-
// Stop the event going any further.
|
|
270
|
-
// For cancellation events, like the Escape key, we generally should
|
|
271
|
-
// air on the side of caution and only allow it to cancel one thing.
|
|
272
|
-
// So, it's polite for us to stop propagation of the event.
|
|
273
|
-
// Otherwise, we end up with UX where one Escape key press
|
|
274
|
-
// unexpectedly cancels multiple things.
|
|
275
|
-
e.preventDefault();
|
|
276
|
-
e.stopPropagation();
|
|
277
|
-
this.props.onClose();
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
render(): React.ReactElement | null {
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const styles = StyleSheet.create({
|
|
287
|
-
container: {
|
|
288
|
-
// This z-index is copied from the Khan Academy webapp.
|
|
289
|
-
//
|
|
290
|
-
// TODO(mdr): Should we keep this in a constants file somewhere? Or
|
|
291
|
-
// not hardcode it at all, and provide it to Wonder Blocks via
|
|
292
|
-
// configuration?
|
|
293
|
-
zIndex: 1080,
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
export default withActionScheduler(ModalLauncher);
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
|
|
3
|
-
import type {StyleType} from "@khanacademy/wonder-blocks-core";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
ThemedStylesFn,
|
|
7
|
-
useScopedTheme,
|
|
8
|
-
useStyles,
|
|
9
|
-
} from "@khanacademy/wonder-blocks-theming";
|
|
10
|
-
import ModalContent from "./modal-content";
|
|
11
|
-
import ModalHeader from "./modal-header";
|
|
12
|
-
import ModalFooter from "./modal-footer";
|
|
13
|
-
import CloseButton from "./close-button";
|
|
14
|
-
import {
|
|
15
|
-
ModalDialogThemeContext,
|
|
16
|
-
ModalDialogThemeContract,
|
|
17
|
-
} from "../themes/themed-modal-dialog";
|
|
18
|
-
|
|
19
|
-
type Props = {
|
|
20
|
-
/**
|
|
21
|
-
* The main contents of the ModalPanel. All other parts of the panel
|
|
22
|
-
* are positioned around it.
|
|
23
|
-
*/
|
|
24
|
-
content:
|
|
25
|
-
| React.ReactElement<PropsFor<typeof ModalContent>>
|
|
26
|
-
| React.ReactNode;
|
|
27
|
-
/**
|
|
28
|
-
* The modal header to show at the top of the panel.
|
|
29
|
-
*/
|
|
30
|
-
header?: React.ReactElement<PropsFor<typeof ModalHeader>> | React.ReactNode;
|
|
31
|
-
/**
|
|
32
|
-
* A footer to show beneath the contents.
|
|
33
|
-
*/
|
|
34
|
-
footer?: React.ReactElement<PropsFor<typeof ModalFooter>> | React.ReactNode;
|
|
35
|
-
/**
|
|
36
|
-
* When true, the close button is shown; otherwise, the close button is not shown.
|
|
37
|
-
*/
|
|
38
|
-
closeButtonVisible: boolean;
|
|
39
|
-
/**
|
|
40
|
-
* Should the contents of the panel become scrollable should they
|
|
41
|
-
* become too tall?
|
|
42
|
-
*/
|
|
43
|
-
scrollOverflow: boolean;
|
|
44
|
-
/**
|
|
45
|
-
* Whether to display the "light" version of this component instead, for
|
|
46
|
-
* use when the item is used on a dark background.
|
|
47
|
-
*/
|
|
48
|
-
light: boolean;
|
|
49
|
-
/**
|
|
50
|
-
* Any optional styling to apply to the panel.
|
|
51
|
-
*/
|
|
52
|
-
style?: StyleType;
|
|
53
|
-
/**
|
|
54
|
-
* Called when the close button is clicked.
|
|
55
|
-
*
|
|
56
|
-
* If you're using `ModalLauncher`, you should not use this prop!
|
|
57
|
-
* Instead, to listen for when the modal closes, add an `onClose` handler
|
|
58
|
-
* to the `ModalLauncher`. Doing so will throw an error.
|
|
59
|
-
*/
|
|
60
|
-
onClose?: () => unknown;
|
|
61
|
-
/**
|
|
62
|
-
* Test ID used for e2e testing.
|
|
63
|
-
*
|
|
64
|
-
* In this case, this `testId` comes from the `testId` prop defined in the
|
|
65
|
-
* Dialog variant (e.g. OnePaneDialog).
|
|
66
|
-
*/
|
|
67
|
-
testId?: string;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* ModalPanel is the content container.
|
|
72
|
-
*
|
|
73
|
-
* **Implementation notes:**
|
|
74
|
-
*
|
|
75
|
-
* If you are creating a custom Dialog, make sure to follow these guidelines:
|
|
76
|
-
* - Make sure to add this component inside the [ModalDialog](/#modaldialog).
|
|
77
|
-
* - If needed, you can also add a [ModalHeader](/#modalheader) using the
|
|
78
|
-
* `header` prop. Same goes for [ModalFooter](/#modalfooter).
|
|
79
|
-
* - If you need to create e2e tests, make sure to pass a `testId` prop. This
|
|
80
|
-
* will be passed down to this component using a sufix: e.g.
|
|
81
|
-
* `some-random-id-ModalPanel`. This scope will be propagated to the
|
|
82
|
-
* CloseButton element as well: e.g. `some-random-id-CloseButton`.
|
|
83
|
-
*
|
|
84
|
-
* ```js
|
|
85
|
-
* <ModalDialog>
|
|
86
|
-
* <ModalPanel content={"custom content goes here"} />
|
|
87
|
-
* </ModalDialog>
|
|
88
|
-
* ```
|
|
89
|
-
*/
|
|
90
|
-
export default function ModalPanel({
|
|
91
|
-
closeButtonVisible = true,
|
|
92
|
-
scrollOverflow = true,
|
|
93
|
-
light = true,
|
|
94
|
-
content,
|
|
95
|
-
footer,
|
|
96
|
-
header,
|
|
97
|
-
onClose,
|
|
98
|
-
style,
|
|
99
|
-
testId,
|
|
100
|
-
}: Props) {
|
|
101
|
-
const {theme} = useScopedTheme(ModalDialogThemeContext);
|
|
102
|
-
const styles = useStyles(themedStylesFn, theme);
|
|
103
|
-
|
|
104
|
-
const renderMainContent = React.useCallback((): React.ReactNode => {
|
|
105
|
-
const mainContent = ModalContent.isComponentOf(content) ? (
|
|
106
|
-
(content as React.ReactElement<PropsFor<typeof ModalContent>>)
|
|
107
|
-
) : (
|
|
108
|
-
<ModalContent>{content}</ModalContent>
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
if (!mainContent) {
|
|
112
|
-
return mainContent;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return React.cloneElement(mainContent, {
|
|
116
|
-
// Pass the scrollOverflow and header in to the main content
|
|
117
|
-
scrollOverflow,
|
|
118
|
-
// We override the styling of the main content to help position
|
|
119
|
-
// it if there is a footer or close button being
|
|
120
|
-
// shown. We have to do this here as the ModalContent doesn't
|
|
121
|
-
// know about things being positioned around it.
|
|
122
|
-
style: [!!footer && styles.hasFooter, mainContent.props.style],
|
|
123
|
-
});
|
|
124
|
-
}, [content, footer, scrollOverflow, styles.hasFooter]);
|
|
125
|
-
|
|
126
|
-
const mainContent = renderMainContent();
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<View
|
|
130
|
-
style={[styles.wrapper, !light && styles.dark, style]}
|
|
131
|
-
testId={testId && `${testId}-panel`}
|
|
132
|
-
>
|
|
133
|
-
{closeButtonVisible && (
|
|
134
|
-
<CloseButton
|
|
135
|
-
light={!light}
|
|
136
|
-
onClick={onClose}
|
|
137
|
-
style={styles.closeButton}
|
|
138
|
-
testId={testId && `${testId}-close`}
|
|
139
|
-
/>
|
|
140
|
-
)}
|
|
141
|
-
{header}
|
|
142
|
-
{mainContent}
|
|
143
|
-
{!footer || ModalFooter.isComponentOf(footer) ? (
|
|
144
|
-
footer
|
|
145
|
-
) : (
|
|
146
|
-
<ModalFooter>{footer}</ModalFooter>
|
|
147
|
-
)}
|
|
148
|
-
</View>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
ModalPanel.defaultProps = {
|
|
153
|
-
closeButtonVisible: true,
|
|
154
|
-
scrollOverflow: true,
|
|
155
|
-
light: true,
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const themedStylesFn: ThemedStylesFn<ModalDialogThemeContract> = (theme) => ({
|
|
159
|
-
wrapper: {
|
|
160
|
-
flex: "1 1 auto",
|
|
161
|
-
position: "relative",
|
|
162
|
-
display: "flex",
|
|
163
|
-
flexDirection: "column",
|
|
164
|
-
background: "white",
|
|
165
|
-
boxSizing: "border-box",
|
|
166
|
-
overflow: "hidden",
|
|
167
|
-
height: "100%",
|
|
168
|
-
width: "100%",
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
closeButton: {
|
|
172
|
-
position: "absolute",
|
|
173
|
-
right: theme.spacing.panel.closeButton,
|
|
174
|
-
top: theme.spacing.panel.closeButton,
|
|
175
|
-
// This is to allow the button to be tab-ordered before the modal
|
|
176
|
-
// content but still be above the header and content.
|
|
177
|
-
zIndex: 1,
|
|
178
|
-
},
|
|
179
|
-
|
|
180
|
-
dark: {
|
|
181
|
-
background: theme.color.bg.inverse,
|
|
182
|
-
color: theme.color.text.inverse,
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
hasFooter: {
|
|
186
|
-
paddingBottom: theme.spacing.panel.footer,
|
|
187
|
-
},
|
|
188
|
-
});
|