diff --git a/web/packages/design/src/Modal/Modal.jsx b/web/packages/design/src/Modal/Modal.tsx similarity index 52% rename from web/packages/design/src/Modal/Modal.jsx rename to web/packages/design/src/Modal/Modal.tsx index 35c3cf250b37b..9a93b583f8beb 100644 --- a/web/packages/design/src/Modal/Modal.jsx +++ b/web/packages/design/src/Modal/Modal.tsx @@ -16,15 +16,99 @@ * along with this program. If not, see . */ -import React from 'react'; -import styled from 'styled-components'; -import PropTypes from 'prop-types'; +import React, { createRef, cloneElement } from 'react'; +import styled, { StyleFunction } from 'styled-components'; +import { createPortal } from 'react-dom'; -import { ownerDocument } from './../utils'; -import Portal from './Portal'; -import RootRef from './RootRef'; +type Props = { + /** + * If `true`, the modal is open. + */ + open: boolean; + + className?: string; + + /** + * Styles passed to the modal, the parent of the children. + */ + // TODO(ravicious): The type for modalCss might need some work after we migrate the components + // that use to TypeScript. + modalCss?: StyleFunction; + + /** + * The child must be a single HTML element, as Modal calls methods such as focus and setAttribute + * on it. + */ + children?: React.ReactElement; + + /** + * Properties applied to the Backdrop element. + */ + BackdropProps?: BackdropProps; + + /** + * If `true`, the modal will not automatically shift focus to itself when it opens, and + * replace it to the last focused element when it closes. + * This also works correctly with any modal children that have the `disableAutoFocus` prop. + * + * Generally this should never be set to `true` as it makes the modal less + * accessible to assistive technologies, like screen readers. + */ + disableAutoFocus?: boolean; + + /** + * If `true`, clicking the backdrop will not fire any callback. + */ + disableBackdropClick?: boolean; + + /** + * If `true`, the modal will not prevent focus from leaving the modal while open. + * + * Generally this should never be set to `true` as it makes the modal less + * accessible to assistive technologies, like screen readers. + */ + disableEnforceFocus?: boolean; -export default class Modal extends React.Component { + /** + * If `true`, hitting escape will not fire any callback. + */ + disableEscapeKeyDown?: boolean; + + /** + * If `true`, the modal will not restore focus to previously focused element once + * modal is hidden. + */ + disableRestoreFocus?: boolean; + + /** + * If `true`, the backdrop is not rendered. + */ + hideBackdrop?: boolean; + + /** + * Callback fired when the backdrop is clicked. + */ + onBackdropClick?: (event: React.MouseEvent) => void; + + /** + * Callback fired when the component requests to be closed. + * The `reason` parameter can optionally be used to control the response to `onClose`. + */ + onClose?: ( + event: KeyboardEvent | React.MouseEvent, + reason: 'escapeKeyDown' | 'backdropClick' + ) => void; + + /** + * Callback fired when the escape key is pressed, + * `disableEscapeKeyDown` is false and the modal is in focus. + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; +}; + +export default class Modal extends React.Component { + lastFocus: HTMLElement | undefined; + modalRef = createRef(); mounted = false; componentDidMount() { @@ -34,11 +118,11 @@ export default class Modal extends React.Component { } } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { if (prevProps.open && !this.props.open) { this.handleClose(); } else if (!prevProps.open && this.props.open) { - this.lastFocus = ownerDocument(this.mountNode).activeElement; + this.lastFocus = document.activeElement as HTMLElement; this.handleOpen(); } } @@ -50,12 +134,26 @@ export default class Modal extends React.Component { } } + dialogEl = (): Element => { + const modalEl = this.modalRef.current; + if (!modalEl) { + return; + } + + const isBackdropRenderedFirst = !this.props.hideBackdrop; + + if (isBackdropRenderedFirst) { + return modalEl.children[1]; + } + + return modalEl.firstElementChild; + }; + handleOpen = () => { - const doc = ownerDocument(this.mountNode); - doc.addEventListener('keydown', this.handleDocumentKeyDown); - doc.addEventListener('focus', this.enforceFocus, true); + document.addEventListener('keydown', this.handleDocumentKeyDown); + document.addEventListener('focus', this.enforceFocus, true); - if (this.dialogRef) { + if (this.dialogEl()) { this.handleOpened(); } }; @@ -63,18 +161,17 @@ export default class Modal extends React.Component { handleOpened = () => { this.autoFocus(); // Fix a bug on Chrome where the scroll isn't initially 0. - this.modalRef.scrollTop = 0; + this.modalRef.current.scrollTop = 0; }; handleClose = () => { - const doc = ownerDocument(this.mountNode); - doc.removeEventListener('keydown', this.handleDocumentKeyDown); - doc.removeEventListener('focus', this.enforceFocus, true); + document.removeEventListener('keydown', this.handleDocumentKeyDown); + document.removeEventListener('focus', this.enforceFocus, true); this.restoreLastFocus(); }; - handleBackdropClick = event => { + handleBackdropClick = (event: React.MouseEvent) => { if (event.target !== event.currentTarget) { return; } @@ -88,13 +185,7 @@ export default class Modal extends React.Component { } }; - handleRendered = () => { - if (this.props.onRendered) { - this.props.onRendered(); - } - }; - - handleDocumentKeyDown = event => { + handleDocumentKeyDown = (event: KeyboardEvent) => { const ESC = 'Escape'; // Ignore events that have been `event.preventDefault()` marked. @@ -113,44 +204,33 @@ export default class Modal extends React.Component { enforceFocus = () => { // The Modal might not already be mounted. - if (this.props.disableEnforceFocus || !this.mounted || !this.dialogRef) { + if (this.props.disableEnforceFocus || !this.mounted || !this.dialogEl()) { return; } - const currentActiveElement = ownerDocument(this.mountNode).activeElement; + const currentActiveElement = document.activeElement; - if (!this.dialogRef.contains(currentActiveElement)) { - this.dialogRef.focus(); + if (!this.dialogEl().contains(currentActiveElement)) { + // Technically, dialogEl can be something else than an HTML element with the focus method. + this.dialogEl()['focus']?.(); } }; - handlePortalRef = ref => { - this.mountNode = ref ? ref.getMountNode() : ref; - }; - - handleModalRef = ref => { - this.modalRef = ref; - }; - - onRootRef = ref => { - this.dialogRef = ref; - }; - autoFocus() { // We might render an empty child. - if (this.props.disableAutoFocus || !this.dialogRef) { + if (this.props.disableAutoFocus || !this.dialogEl()) { return; } - const currentActiveElement = ownerDocument(this.mountNode).activeElement; + const currentActiveElement = document.activeElement as HTMLElement; - if (!this.dialogRef.contains(currentActiveElement)) { - if (!this.dialogRef.hasAttribute('tabIndex')) { - this.dialogRef.setAttribute('tabIndex', -1); + if (!this.dialogEl().contains(currentActiveElement)) { + if (!this.dialogEl().hasAttribute('tabIndex')) { + this.dialogEl().setAttribute('tabIndex', '-1'); } this.lastFocus = currentActiveElement; - this.dialogRef.focus(); + this.dialogEl()['focus']?.(); } } @@ -170,144 +250,40 @@ export default class Modal extends React.Component { } render() { - const { - BackdropProps, - children, - container, - disablePortal, - modalCss, - hideBackdrop, - open, - className, - } = this.props; - - const childProps = {}; + const { BackdropProps, children, modalCss, hideBackdrop, open, className } = + this.props; if (!open) { return null; } - return ( - e.stopPropagation()} > - e.stopPropagation()} - > - {!hideBackdrop && ( - - )} - - {React.cloneElement(children, childProps)} - - - + {!hideBackdrop && ( + + )} + {cloneElement(children, {})} + , + document.body ); } } -Modal.propTypes = { - /** - * Properties applied to the [`Backdrop`](/api/backdrop/) element. - * - * invisible: Boolean - allows backdrop to keep bg color of parent eg: popup menu - */ - BackdropProps: PropTypes.object, - /** - * A single child content element. - */ - children: PropTypes.element, +type BackdropProps = { /** - * A node, component instance, or function that returns either. - * The `container` will have the portal children appended to it. + * Allows backdrop to keep bg color of parent eg: popup menu */ - container: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - /** - * If `true`, the modal will not automatically shift focus to itself when it opens, and - * replace it to the last focused element when it closes. - * This also works correctly with any modal children that have the `disableAutoFocus` prop. - * - * Generally this should never be set to `true` as it makes the modal less - * accessible to assistive technologies, like screen readers. - */ - disableAutoFocus: PropTypes.bool, - /** - * If `true`, clicking the backdrop will not fire any callback. - */ - disableBackdropClick: PropTypes.bool, - /** - * If `true`, the modal will not prevent focus from leaving the modal while open. - * - * Generally this should never be set to `true` as it makes the modal less - * accessible to assistive technologies, like screen readers. - */ - disableEnforceFocus: PropTypes.bool, - /** - * If `true`, hitting escape will not fire any callback. - */ - disableEscapeKeyDown: PropTypes.bool, - /** - * Disable the portal behavior. - * The children stay within it's parent DOM hierarchy. - */ - disablePortal: PropTypes.bool, - /** - * If `true`, the modal will not restore focus to previously focused element once - * modal is hidden. - */ - disableRestoreFocus: PropTypes.bool, - /** - * If `true`, the backdrop is not rendered. - */ - hideBackdrop: PropTypes.bool, - /** - * Callback fired when the backdrop is clicked. - */ - onBackdropClick: PropTypes.func, - /** - * Callback fired when the component requests to be closed. - * The `reason` parameter can optionally be used to control the response to `onClose`. - * - * @param {object} event The event source of the callback - * @param {string} reason Can be:`"escapeKeyDown"`, `"backdropClick"` - */ - onClose: PropTypes.func, - /** - * Callback fired when the escape key is pressed, - * `disableEscapeKeyDown` is false and the modal is in focus. - */ - onEscapeKeyDown: PropTypes.func, - /** - * Callback fired once the children has been mounted into the `container`. - * It signals that the `open={true}` property took effect. - */ - onRendered: PropTypes.func, - /** - * If `true`, the modal is open. - */ - open: PropTypes.bool.isRequired, - className: PropTypes.string, -}; - -Modal.defaultProps = { - disableAutoFocus: false, - disableBackdropClick: false, - disableEnforceFocus: false, - disableEscapeKeyDown: false, - disablePortal: false, - disableRestoreFocus: false, - hideBackdrop: false, + invisible: boolean; + [prop: string]: any; }; -function Backdrop(props) { +function Backdrop(props: BackdropProps) { const { invisible, ...rest } = props; return ( ` z-index: -1; position: fixed; right: 0; @@ -332,12 +308,15 @@ const StyledBackdrop = styled.div` touch-action: none; `; -const StyledModal = styled.div` +const StyledModal = styled.div<{ + modalCss: StyleFunction; + ref: React.ForwardedRef; +}>` position: fixed; z-index: 1200; right: 0; bottom: 0; top: 0; left: 0; - ${props => props.modalCss && props.modalCss(props)} + ${props => props.modalCss?.(props)} `; diff --git a/web/packages/design/src/Modal/Portal.jsx b/web/packages/design/src/Modal/Portal.jsx deleted file mode 100644 index 1e11f0a295309..0000000000000 --- a/web/packages/design/src/Modal/Portal.jsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; - -import { ownerDocument } from './../utils'; - -/** - * Portals provide a first-class way to render children into a DOM node - * that exists outside the DOM hierarchy of the parent component. - */ -class Portal extends React.Component { - componentDidMount() { - this.setMountNode(this.props.container); - - // Only rerender if needed - if (!this.props.disablePortal) { - // Portal initializes the container and mounts it to the DOM during - // first render. No children are rendered at this time. - // ForceUpdate is called to render children elements inside - // the container after it gets mounted. - this.forceUpdate(); - } - } - - componentDidUpdate(prevProps) { - if ( - prevProps.container !== this.props.container || - prevProps.disablePortal !== this.props.disablePortal - ) { - this.setMountNode(this.props.container); - - // Only rerender if needed - if (!this.props.disablePortal) { - this.forceUpdate(); - } - } - } - - componentWillUnmount() { - this.mountNode = null; - } - - setMountNode(container) { - if (this.props.disablePortal) { - this.mountNode = ReactDOM.findDOMNode(this).parentElement; - } else { - this.mountNode = getContainer(container, getOwnerDocument(this).body); - } - } - - /** - * @public - */ - getMountNode = () => { - return this.mountNode; - }; - - render() { - const { children, disablePortal } = this.props; - - if (disablePortal) { - return children; - } - - return this.mountNode - ? ReactDOM.createPortal(children, this.mountNode) - : null; - } -} - -Portal.propTypes = { - /** - * The children to render into the `container`. - */ - children: PropTypes.node.isRequired, - /** - * A node, component instance, or function that returns either. - * The `container` will have the portal children appended to it. - * By default, it uses the body of the top-level document object, - * so it's simply `document.body` most of the time. - */ - container: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - /** - * Disable the portal behavior. - * The children stay within it's parent DOM hierarchy. - */ - disablePortal: PropTypes.bool, -}; - -Portal.defaultProps = { - disablePortal: false, -}; - -function getContainer(container, defaultContainer) { - container = typeof container === 'function' ? container() : container; - return ReactDOM.findDOMNode(container) || defaultContainer; -} - -function getOwnerDocument(element) { - return ownerDocument(ReactDOM.findDOMNode(element)); -} - -export default Portal; diff --git a/web/packages/design/src/Modal/Portal.test.tsx b/web/packages/design/src/Modal/Portal.test.tsx deleted file mode 100644 index 5c2a0b0129185..0000000000000 --- a/web/packages/design/src/Modal/Portal.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; -import { screen } from '@testing-library/react'; - -import { render } from 'design/utils/testing'; - -import Portal from './Portal'; - -describe('design/Modal/Portal', () => { - test('container to be attached to body element', () => { - const { container } = renderPortal({}); - const content = screen.getByTestId('content'); - expect(container).not.toContainElement(content); - expect(document.body).toContainElement(screen.getByTestId('parent')); - }); - - test('container to be attached to custom element', () => { - const customElement = document.createElement('div'); - renderPortal({ container: customElement }); - expect(screen.queryByTestId('content')).not.toBeInTheDocument(); - expect(customElement).toHaveTextContent('hello'); - }); - - test('disable the portal behavior', () => { - const { container } = renderPortal({ disablePortal: true }); - expect(container).toContainElement(screen.getByTestId('content')); - }); -}); - -function renderPortal(props) { - return render( -
- -
hello
-
-
- ); -} diff --git a/web/packages/design/src/Modal/RootRef.jsx b/web/packages/design/src/Modal/RootRef.jsx deleted file mode 100644 index b4f49a6e280dd..0000000000000 --- a/web/packages/design/src/Modal/RootRef.jsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; - -class RootRef extends React.Component { - componentDidMount() { - this.ref = ReactDOM.findDOMNode(this); - setRef(this.props.rootRef, this.ref); - } - - componentDidUpdate(prevProps) { - const ref = ReactDOM.findDOMNode(this); - - if (prevProps.rootRef !== this.props.rootRef || this.ref !== ref) { - if (prevProps.rootRef !== this.props.rootRef) { - setRef(prevProps.rootRef, null); - } - - this.ref = ref; - setRef(this.props.rootRef, this.ref); - } - } - - componentWillUnmount() { - this.ref = null; - setRef(this.props.rootRef, null); - } - - render() { - return this.props.children; - } -} - -function setRef(ref, value) { - if (typeof ref === 'function') { - ref(value); - } else if (ref) { - ref.current = value; - } -} - -RootRef.propTypes = { - /** - * The wrapped element. - */ - children: PropTypes.element.isRequired, - /** - * Provide a way to access the DOM node of the wrapped element. - * You can provide a callback ref or a `React.createRef()` ref. - */ - rootRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, -}; - -export default RootRef; diff --git a/web/packages/design/src/Popover/Popover.jsx b/web/packages/design/src/Popover/Popover.jsx index 00d2857593178..0a4403b775d3b 100644 --- a/web/packages/design/src/Popover/Popover.jsx +++ b/web/packages/design/src/Popover/Popover.jsx @@ -40,12 +40,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React from 'react'; +import React, { createRef } from 'react'; import styled from 'styled-components'; import PropTypes from 'prop-types'; -import ReactDOM from 'react-dom'; -import { ownerWindow, ownerDocument } from '../utils'; import Modal from '../Modal'; import Transition from './Transition'; @@ -103,6 +101,7 @@ function getAnchorEl(anchorEl) { } export default class Popover extends React.Component { + paperRef = createRef(); handleGetOffsetTop = getOffsetTop; handleGetOffsetLeft = getOffsetLeft; @@ -118,7 +117,7 @@ export default class Popover extends React.Component { return; } - this.setPositioningStyles(this.paperRef); + this.setPositioningStyles(this.paperRef.current); }; } } @@ -153,7 +152,7 @@ export default class Popover extends React.Component { }; getPositioningStyle = element => { - const { anchorEl, anchorReference, marginThreshold } = this.props; + const { anchorReference, marginThreshold } = this.props; // Check if the parent has requested anchoring on an inner content node const contentAnchorOffset = this.getContentAnchorOffset(element); @@ -189,12 +188,9 @@ export default class Popover extends React.Component { let bottom = top + elemRect.height; let right = left + elemRect.width; - // Use the parent window of the anchorEl if provided - const containerWindow = ownerWindow(getAnchorEl(anchorEl)); - // Window thresholds taking required margin into account - const heightThreshold = containerWindow.innerHeight - marginThreshold; - const widthThreshold = containerWindow.innerWidth - marginThreshold; + const heightThreshold = window.innerHeight - marginThreshold; + const widthThreshold = window.innerWidth - marginThreshold; // Check if the vertical axis needs shifting if (top < marginThreshold) { @@ -224,8 +220,8 @@ export default class Popover extends React.Component { return { top: `${top}px`, left: `${left}px`, - bottom: `${containerWindow.innerHeight - bottom}px`, - right: `${containerWindow.innerWidth - right}px`, + bottom: `${window.innerHeight - bottom}px`, + right: `${window.innerWidth - right}px`, transformOrigin: getTransformOriginValue(transformOrigin), }; }; @@ -236,8 +232,7 @@ export default class Popover extends React.Component { const { anchorEl, anchorOrigin } = this.props; // If an anchor element wasn't provided, just use the parent body element of this Popover - const anchorElement = - getAnchorEl(anchorEl) || ownerDocument(this.paperRef).body; + const anchorElement = getAnchorEl(anchorEl) || document.body; const anchorRect = anchorElement.getBoundingClientRect(); @@ -301,25 +296,10 @@ export default class Popover extends React.Component { }; render() { - const { - anchorEl, - children, - container: containerProp, - open, - popoverCss, - ...other - } = this.props; - - // If the container prop is provided, use that - // If the anchorEl prop is provided, use its parent body element as the container - // If neither are provided let the Modal take care of choosing the container - const container = - containerProp || - (anchorEl ? ownerDocument(getAnchorEl(anchorEl)).body : undefined); + const { children, open, popoverCss, ...other } = this.props; return ( { - this.paperRef = ReactDOM.findDOMNode(ref); - }} + ref={this.paperRef} > {children} @@ -398,15 +376,6 @@ Popover.propTypes = { * The content of the component. */ children: PropTypes.node, - /** - * A node, component instance, or function that returns either. - * The `container` will passed to the Modal component. - * By default, it uses the body of the anchorEl's top-level document object, - * so it's simply `document.body` most of the time. - */ - container: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - /** - */ /** * This function is called in order to retrieve the content anchor element. * It's the opposite of the `anchorEl` property. diff --git a/web/packages/design/src/utils/index.ts b/web/packages/design/src/utils/index.ts deleted file mode 100644 index 6cac5e7bddc2b..0000000000000 --- a/web/packages/design/src/utils/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -export function ownerDocument(node?: Element) { - return (node && node.ownerDocument) || document; -} - -export function ownerWindow(node?: Element): Window { - const doc = ownerDocument(node); - return (doc && doc.defaultView) || window; -}