From b5f565678cd246b3dd4b101a64a3d2b5ff2d8f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Mon, 9 Sep 2024 19:42:34 +0200 Subject: [PATCH] Revert "Remove findDOMNode usage from Modal and Popover" (#46398) This reverts commit 50ad9286f57c25962e4e2fa5beee2ee60892fe52. --- .../design/src/Modal/{Modal.tsx => Modal.jsx} | 327 ++++++++++-------- web/packages/design/src/Modal/Portal.jsx | 121 +++++++ web/packages/design/src/Modal/Portal.test.tsx | 55 +++ web/packages/design/src/Modal/RootRef.jsx | 72 ++++ web/packages/design/src/Popover/Popover.jsx | 53 ++- web/packages/design/src/utils/index.ts | 26 ++ 6 files changed, 490 insertions(+), 164 deletions(-) rename web/packages/design/src/Modal/{Modal.tsx => Modal.jsx} (52%) create mode 100644 web/packages/design/src/Modal/Portal.jsx create mode 100644 web/packages/design/src/Modal/Portal.test.tsx create mode 100644 web/packages/design/src/Modal/RootRef.jsx create mode 100644 web/packages/design/src/utils/index.ts diff --git a/web/packages/design/src/Modal/Modal.tsx b/web/packages/design/src/Modal/Modal.jsx similarity index 52% rename from web/packages/design/src/Modal/Modal.tsx rename to web/packages/design/src/Modal/Modal.jsx index 9a93b583f8be..35c3cf250b37 100644 --- a/web/packages/design/src/Modal/Modal.tsx +++ b/web/packages/design/src/Modal/Modal.jsx @@ -16,99 +16,15 @@ * along with this program. If not, see . */ -import React, { createRef, cloneElement } from 'react'; -import styled, { StyleFunction } from 'styled-components'; -import { createPortal } from 'react-dom'; +import React from 'react'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; -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; +import { ownerDocument } from './../utils'; +import Portal from './Portal'; +import RootRef from './RootRef'; - /** - * 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(); +export default class Modal extends React.Component { mounted = false; componentDidMount() { @@ -118,11 +34,11 @@ export default class Modal extends React.Component { } } - componentDidUpdate(prevProps: Props) { + componentDidUpdate(prevProps) { if (prevProps.open && !this.props.open) { this.handleClose(); } else if (!prevProps.open && this.props.open) { - this.lastFocus = document.activeElement as HTMLElement; + this.lastFocus = ownerDocument(this.mountNode).activeElement; this.handleOpen(); } } @@ -134,26 +50,12 @@ 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 = () => { - document.addEventListener('keydown', this.handleDocumentKeyDown); - document.addEventListener('focus', this.enforceFocus, true); + const doc = ownerDocument(this.mountNode); + doc.addEventListener('keydown', this.handleDocumentKeyDown); + doc.addEventListener('focus', this.enforceFocus, true); - if (this.dialogEl()) { + if (this.dialogRef) { this.handleOpened(); } }; @@ -161,17 +63,18 @@ export default class Modal extends React.Component { handleOpened = () => { this.autoFocus(); // Fix a bug on Chrome where the scroll isn't initially 0. - this.modalRef.current.scrollTop = 0; + this.modalRef.scrollTop = 0; }; handleClose = () => { - document.removeEventListener('keydown', this.handleDocumentKeyDown); - document.removeEventListener('focus', this.enforceFocus, true); + const doc = ownerDocument(this.mountNode); + doc.removeEventListener('keydown', this.handleDocumentKeyDown); + doc.removeEventListener('focus', this.enforceFocus, true); this.restoreLastFocus(); }; - handleBackdropClick = (event: React.MouseEvent) => { + handleBackdropClick = event => { if (event.target !== event.currentTarget) { return; } @@ -185,7 +88,13 @@ export default class Modal extends React.Component { } }; - handleDocumentKeyDown = (event: KeyboardEvent) => { + handleRendered = () => { + if (this.props.onRendered) { + this.props.onRendered(); + } + }; + + handleDocumentKeyDown = event => { const ESC = 'Escape'; // Ignore events that have been `event.preventDefault()` marked. @@ -204,33 +113,44 @@ export default class Modal extends React.Component { enforceFocus = () => { // The Modal might not already be mounted. - if (this.props.disableEnforceFocus || !this.mounted || !this.dialogEl()) { + if (this.props.disableEnforceFocus || !this.mounted || !this.dialogRef) { return; } - const currentActiveElement = document.activeElement; + const currentActiveElement = ownerDocument(this.mountNode).activeElement; - if (!this.dialogEl().contains(currentActiveElement)) { - // Technically, dialogEl can be something else than an HTML element with the focus method. - this.dialogEl()['focus']?.(); + if (!this.dialogRef.contains(currentActiveElement)) { + this.dialogRef.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.dialogEl()) { + if (this.props.disableAutoFocus || !this.dialogRef) { return; } - const currentActiveElement = document.activeElement as HTMLElement; + const currentActiveElement = ownerDocument(this.mountNode).activeElement; - if (!this.dialogEl().contains(currentActiveElement)) { - if (!this.dialogEl().hasAttribute('tabIndex')) { - this.dialogEl().setAttribute('tabIndex', '-1'); + if (!this.dialogRef.contains(currentActiveElement)) { + if (!this.dialogRef.hasAttribute('tabIndex')) { + this.dialogRef.setAttribute('tabIndex', -1); } this.lastFocus = currentActiveElement; - this.dialogEl()['focus']?.(); + this.dialogRef.focus(); } } @@ -250,40 +170,144 @@ export default class Modal extends React.Component { } render() { - const { BackdropProps, children, modalCss, hideBackdrop, open, className } = - this.props; + const { + BackdropProps, + children, + container, + disablePortal, + modalCss, + hideBackdrop, + open, + className, + } = this.props; + + const childProps = {}; if (!open) { return null; } - return createPortal( - e.stopPropagation()} + return ( + - {!hideBackdrop && ( - - )} - {cloneElement(children, {})} - , - document.body + e.stopPropagation()} + > + {!hideBackdrop && ( + + )} + + {React.cloneElement(children, childProps)} + + + ); } } -type BackdropProps = { +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, /** - * Allows backdrop to keep bg color of parent eg: popup menu + * A node, component instance, or function that returns either. + * The `container` will have the portal children appended to it. */ - invisible: boolean; - [prop: string]: any; + 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, }; -function Backdrop(props: BackdropProps) { +function Backdrop(props) { const { invisible, ...rest } = props; return ( ` +const StyledBackdrop = styled.div` z-index: -1; position: fixed; right: 0; @@ -308,15 +332,12 @@ const StyledBackdrop = styled.div` touch-action: none; `; -const StyledModal = styled.div<{ - modalCss: StyleFunction; - ref: React.ForwardedRef; -}>` +const StyledModal = styled.div` position: fixed; z-index: 1200; right: 0; bottom: 0; top: 0; left: 0; - ${props => props.modalCss?.(props)} + ${props => props.modalCss && props.modalCss(props)} `; diff --git a/web/packages/design/src/Modal/Portal.jsx b/web/packages/design/src/Modal/Portal.jsx new file mode 100644 index 000000000000..1e11f0a29530 --- /dev/null +++ b/web/packages/design/src/Modal/Portal.jsx @@ -0,0 +1,121 @@ +/* + * 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 new file mode 100644 index 000000000000..5c2a0b012918 --- /dev/null +++ b/web/packages/design/src/Modal/Portal.test.tsx @@ -0,0 +1,55 @@ +/** + * 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 new file mode 100644 index 000000000000..b4f49a6e280d --- /dev/null +++ b/web/packages/design/src/Modal/RootRef.jsx @@ -0,0 +1,72 @@ +/* + * 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 0a4403b775d3..00d285759317 100644 --- a/web/packages/design/src/Popover/Popover.jsx +++ b/web/packages/design/src/Popover/Popover.jsx @@ -40,10 +40,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React, { createRef } from 'react'; +import React 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'; @@ -101,7 +103,6 @@ function getAnchorEl(anchorEl) { } export default class Popover extends React.Component { - paperRef = createRef(); handleGetOffsetTop = getOffsetTop; handleGetOffsetLeft = getOffsetLeft; @@ -117,7 +118,7 @@ export default class Popover extends React.Component { return; } - this.setPositioningStyles(this.paperRef.current); + this.setPositioningStyles(this.paperRef); }; } } @@ -152,7 +153,7 @@ export default class Popover extends React.Component { }; getPositioningStyle = element => { - const { anchorReference, marginThreshold } = this.props; + const { anchorEl, anchorReference, marginThreshold } = this.props; // Check if the parent has requested anchoring on an inner content node const contentAnchorOffset = this.getContentAnchorOffset(element); @@ -188,9 +189,12 @@ 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 = window.innerHeight - marginThreshold; - const widthThreshold = window.innerWidth - marginThreshold; + const heightThreshold = containerWindow.innerHeight - marginThreshold; + const widthThreshold = containerWindow.innerWidth - marginThreshold; // Check if the vertical axis needs shifting if (top < marginThreshold) { @@ -220,8 +224,8 @@ export default class Popover extends React.Component { return { top: `${top}px`, left: `${left}px`, - bottom: `${window.innerHeight - bottom}px`, - right: `${window.innerWidth - right}px`, + bottom: `${containerWindow.innerHeight - bottom}px`, + right: `${containerWindow.innerWidth - right}px`, transformOrigin: getTransformOriginValue(transformOrigin), }; }; @@ -232,7 +236,8 @@ 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) || document.body; + const anchorElement = + getAnchorEl(anchorEl) || ownerDocument(this.paperRef).body; const anchorRect = anchorElement.getBoundingClientRect(); @@ -296,10 +301,25 @@ export default class Popover extends React.Component { }; render() { - const { children, open, popoverCss, ...other } = this.props; + 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); return ( { + this.paperRef = ReactDOM.findDOMNode(ref); + }} > {children} @@ -376,6 +398,15 @@ 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 new file mode 100644 index 000000000000..6cac5e7bddc2 --- /dev/null +++ b/web/packages/design/src/utils/index.ts @@ -0,0 +1,26 @@ +/** + * 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; +}