diff --git a/react-responsive-modal/package.json b/react-responsive-modal/package.json index 56fc2c70..1ffe3e3c 100644 --- a/react-responsive-modal/package.json +++ b/react-responsive-modal/package.json @@ -55,6 +55,7 @@ } ], "dependencies": { + "@bedrock-layout/use-forwarded-ref": "^1.1.4", "body-scroll-lock": "^3.1.5", "classnames": "^2.2.6" }, diff --git a/react-responsive-modal/src/FocusTrap.tsx b/react-responsive-modal/src/FocusTrap.tsx index ccfc7b4b..3f4f7735 100644 --- a/react-responsive-modal/src/FocusTrap.tsx +++ b/react-responsive-modal/src/FocusTrap.tsx @@ -7,7 +7,7 @@ import { } from './lib/focusTrapJs'; export interface FocusTrapOptions { - focusOn?: 'firstFocusableElement' | 'modalRoot'; + focusOn?: React.RefObject; } interface FocusTrapProps { @@ -44,15 +44,18 @@ export const FocusTrap = ({ container, options }: FocusTrapProps) => { } }; - if (options?.focusOn === 'firstFocusableElement') { + if (options?.focusOn) { + savePreviousFocus(); + // We need to schedule focusing on a next frame - this allows to focus on the modal root + requestAnimationFrame(() => { + options.focusOn?.current?.focus(); + }); + } else { const allTabbingElements = getAllTabbingElements(container.current); if (allTabbingElements[0]) { savePreviousFocus(); allTabbingElements[0].focus(); } - } else if (options?.focusOn === 'modalRoot') { - savePreviousFocus(); - container?.current?.focus(); } } return () => { diff --git a/react-responsive-modal/src/index.tsx b/react-responsive-modal/src/index.tsx index a4fe25e5..d2425b98 100644 --- a/react-responsive-modal/src/index.tsx +++ b/react-responsive-modal/src/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import ReactDom from 'react-dom'; import cx from 'classnames'; import CloseIcon from './CloseIcon'; @@ -6,6 +6,7 @@ import { FocusTrap, FocusTrapOptions } from './FocusTrap'; import { modalManager, useModalManager } from './modalManager'; import { useScrollLock } from './useScrollLock'; import { isBrowser } from './utils'; +import useForwardedRef from '@bedrock-layout/use-forwarded-ref'; const classes = { root: 'react-responsive-modal-root', @@ -70,9 +71,9 @@ export interface ModalProps { */ focusTrapped?: boolean; /** - * Options focus trapping. + * Options for focus trapping. * - * Default to { focusOn: 'firstFocusableElement' }. + * Default to undefined. */ focusTrapOptions?: FocusTrapOptions; /** @@ -152,214 +153,219 @@ export interface ModalProps { children?: React.ReactNode; } -export const Modal = ({ - open, - center, - blockScroll = true, - closeOnEsc = true, - closeOnOverlayClick = true, - container, - showCloseIcon = true, - closeIconId, - closeIcon, - focusTrapped = true, - focusTrapOptions = { focusOn: 'firstFocusableElement' }, - animationDuration = 300, - classNames, - styles, - role = 'dialog', - ariaDescribedby, - ariaLabelledby, - modalId, - onClose, - onEscKeyDown, - onOverlayClick, - onAnimationEnd, - children, -}: ModalProps) => { - const refModal = useRef(null); - const refDialog = useRef(null); - const refShouldClose = useRef(null); - const refContainer = useRef(null); - // Lazily create the ref instance - // https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily - if (refContainer.current === null && isBrowser) { - refContainer.current = document.createElement('div'); - } - - // The value should be false for srr, that way when the component is hydrated client side, - // it will match the server rendered content - const [showPortal, setShowPortal] = useState(false); +export const Modal = React.forwardRef( + ( + { + open, + center, + blockScroll = true, + closeOnEsc = true, + closeOnOverlayClick = true, + container, + showCloseIcon = true, + closeIconId, + closeIcon, + focusTrapped = true, + focusTrapOptions = undefined, + animationDuration = 300, + classNames, + styles, + role = 'dialog', + ariaDescribedby, + ariaLabelledby, + modalId, + onClose, + onEscKeyDown, + onOverlayClick, + onAnimationEnd, + children, + }: ModalProps, + ref: React.ForwardedRef + ) => { + const refDialog = useForwardedRef(ref); + const refModal = useRef(null); + const refShouldClose = useRef(null); + const refContainer = useRef(null); + // Lazily create the ref instance + // https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily + if (refContainer.current === null && isBrowser) { + refContainer.current = document.createElement('div'); + } - // Hook used to manage multiple modals opened at the same time - useModalManager(refModal, open); + // The value should be false for srr, that way when the component is hydrated client side, + // it will match the server rendered content + const [showPortal, setShowPortal] = useState(false); - // Hook used to manage the scroll - useScrollLock(refModal, open, showPortal, blockScroll); + // Hook used to manage multiple modals opened at the same time + useModalManager(refModal, open); - const handleOpen = () => { - if ( - refContainer.current && - !container && - !document.body.contains(refContainer.current) - ) { - document.body.appendChild(refContainer.current); - } + // Hook used to manage the scroll + useScrollLock(refModal, open, showPortal, blockScroll); - document.addEventListener('keydown', handleKeydown); - }; + const handleOpen = () => { + if ( + refContainer.current && + !container && + !document.body.contains(refContainer.current) + ) { + document.body.appendChild(refContainer.current); + } - const handleClose = () => { - if ( - refContainer.current && - !container && - document.body.contains(refContainer.current) - ) { - document.body.removeChild(refContainer.current); - } - document.removeEventListener('keydown', handleKeydown); - }; + document.addEventListener('keydown', handleKeydown); + }; - const handleKeydown = (event: KeyboardEvent) => { - // Only the last modal need to be escaped when pressing the esc key - if (event.keyCode !== 27 || !modalManager.isTopModal(refModal)) { - return; - } + const handleClose = () => { + if ( + refContainer.current && + !container && + document.body.contains(refContainer.current) + ) { + document.body.removeChild(refContainer.current); + } + document.removeEventListener('keydown', handleKeydown); + }; - onEscKeyDown?.(event); + const handleKeydown = (event: KeyboardEvent) => { + // Only the last modal need to be escaped when pressing the esc key + if (event.keyCode !== 27 || !modalManager.isTopModal(refModal)) { + return; + } - if (closeOnEsc) { - onClose(); - } - }; + onEscKeyDown?.(event); - useEffect(() => { - return () => { - if (showPortal) { - // When the modal is closed or removed directly, cleanup the listeners - handleClose(); + if (closeOnEsc) { + onClose(); } }; - }, [showPortal]); - useEffect(() => { - // If the open prop is changing, we need to open the modal - // This is also called on the first render if the open prop is true when the modal is created - if (open && !showPortal) { - setShowPortal(true); - handleOpen(); - } - }, [open]); + useEffect(() => { + return () => { + if (showPortal) { + // When the modal is closed or removed directly, cleanup the listeners + handleClose(); + } + }; + }, [showPortal]); - const handleClickOverlay = ( - event: React.MouseEvent - ) => { - if (refShouldClose.current === null) { - refShouldClose.current = true; - } + useEffect(() => { + // If the open prop is changing, we need to open the modal + // This is also called on the first render if the open prop is true when the modal is created + if (open && !showPortal) { + setShowPortal(true); + handleOpen(); + } + }, [open]); - if (!refShouldClose.current) { - refShouldClose.current = null; - return; - } + const handleClickOverlay = ( + event: React.MouseEvent + ) => { + if (refShouldClose.current === null) { + refShouldClose.current = true; + } - onOverlayClick?.(event); + if (!refShouldClose.current) { + refShouldClose.current = null; + return; + } - if (closeOnOverlayClick) { - onClose(); - } + onOverlayClick?.(event); - refShouldClose.current = null; - }; + if (closeOnOverlayClick) { + onClose(); + } - const handleModalEvent = () => { - refShouldClose.current = false; - }; + refShouldClose.current = null; + }; - const handleAnimationEnd = () => { - if (!open) { - setShowPortal(false); - } + const handleModalEvent = () => { + refShouldClose.current = false; + }; - onAnimationEnd?.(); - }; + const handleAnimationEnd = () => { + if (!open) { + setShowPortal(false); + } + + onAnimationEnd?.(); + }; - const containerModal = container || refContainer.current; + const containerModal = container || refContainer.current; - const overlayAnimation = open - ? classNames?.overlayAnimationIn ?? classes.overlayAnimationIn - : classNames?.overlayAnimationOut ?? classes.overlayAnimationOut; + const overlayAnimation = open + ? classNames?.overlayAnimationIn ?? classes.overlayAnimationIn + : classNames?.overlayAnimationOut ?? classes.overlayAnimationOut; - const modalAnimation = open - ? classNames?.modalAnimationIn ?? classes.modalAnimationIn - : classNames?.modalAnimationOut ?? classes.modalAnimationOut; + const modalAnimation = open + ? classNames?.modalAnimationIn ?? classes.modalAnimationIn + : classNames?.modalAnimationOut ?? classes.modalAnimationOut; - return showPortal && containerModal - ? ReactDom.createPortal( -
-
+ return showPortal && containerModal + ? ReactDom.createPortal(
- {focusTrapped && ( - - )} - {children} - {showCloseIcon && ( - + /> +
+
+ {focusTrapped && ( + + )} + {children} + {showCloseIcon && ( + + )} +
-
-
, - containerModal - ) - : null; -}; +
, + containerModal + ) + : null; + } +); export default Modal; diff --git a/website/src/components/ExampleRendered.tsx b/website/src/components/ExampleRendered.tsx index 8d77f4c9..78d1d7ea 100644 --- a/website/src/components/ExampleRendered.tsx +++ b/website/src/components/ExampleRendered.tsx @@ -2,7 +2,7 @@ import Simple from '../examples/Simple'; import ExampleMultiple from '../examples/Multiple'; import LongContent from '../examples/LongContent'; import FocusTrapped from '../examples/FocusTrapped'; -import FocusTrappedOnModalRoot from '../examples/FocusTrappedOnModalRoot'; +import FocusTrappedOptions from '../examples/FocusTrappedOptions'; import CustomCssStyle from '../examples/CustomCssStyle'; import CustomAnimation from '../examples/CustomAnimation'; import CustomCloseIcon from '../examples/CustomCloseIcon'; @@ -13,7 +13,7 @@ const examples: Record JSX.Element> = { multiple: ExampleMultiple, longContent: LongContent, focusTrapped: FocusTrapped, - focusTrappedOnModalRoot: FocusTrappedOnModalRoot, + focusTrappedOptions: FocusTrappedOptions, customCssStyle: CustomCssStyle, customAnimation: CustomAnimation, customCloseIcon: CustomCloseIcon, diff --git a/website/src/docs/index.mdx b/website/src/docs/index.mdx index 9738299e..c76b039b 100644 --- a/website/src/docs/index.mdx +++ b/website/src/docs/index.mdx @@ -93,13 +93,13 @@ If you want to disable this behavior, set the `focusTrapped` prop to `false`. ``` -### Focus Trapped on modal root +### Focus Trapped options -You can also set to trap focus within the modal, but not to focus first visible element. To do this use `focusTrapOptions` prop and set `focusOn` to `modalRoot`. +You can also set to trap focus within the modal, but decide where to put focus when opened. To do this use `focusTrapOptions` prop and set `focusOn` to a ref of an element you want to focus. In this example we focus on the modal root element. - + -```js file=../examples/FocusTrappedOnModalRoot.tsx +```js file=../examples/FocusTrappedOptions.tsx ``` @@ -179,30 +179,30 @@ By default, the Modal will be rendered at the end of the html body tag. If you w ## Props -| Name | Type | Default | Description | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **open\*** | `boolean` | | Control if the modal is open or not. | -| **center** | `boolean` | false | Should the dialog be centered. | -| **closeOnEsc** | `boolean` | true | Is the modal closable when user press esc key. | -| **closeOnOverlayClick** | `boolean` | true | Is the modal closable when user click on overlay. | -| **blockScroll** | `boolean` | true | Whether to block scrolling when dialog is open. | -| **showCloseIcon** | `boolean` | true | Show the close icon. | -| **closeIconId** | `string` | | id attribute for the close icon button. | -| **closeIcon** | `React.ReactNode` | | Custom icon to render (svg, img, etc...). | -| **focusTrapped** | `boolean` | true | When the modal is open, trap focus within it. | -| **focusTrapOptions** | `{ focusOn?: 'firstFocusableElement' | 'modalRoot' }` | { focusOn: 'firstFocusableElement' } | When the modal opens focus this element. | -| **container** | `Element` | | You can specify a container prop which should be of type `Element`. The portal will be rendered inside that element. The default behavior will create a div node and render it at the at the end of document.body. | -| **classNames** | `{ root?: string; overlay?: string; overlayAnimationIn?: string; overlayAnimationOut?: string; modal?: string; modalAnimationIn?: string; modalAnimationOut?: string; closeButton?: string; closeIcon?: string; }` | | An object containing classNames to style the modal. | -| **styles** | `{ root?: React.CSSProperties; overlay?: React.CSSProperties; overlay?: React.CSSProperties; modalContainer?: React.CSSProperties; modal?: React.CSSProperties; closeButton?: React.CSSProperties; closeIcon?: React.CSSProperties; }` | | An object containing the styles objects to style the modal. | -| **animationDuration** | `number` | 300 | Animation duration in milliseconds. | -| **role** | `string` | "dialog" | ARIA role for modal | -| **ariaLabelledby** | `string` | | ARIA label for modal | -| **ariaDescribedby** | `string` | | ARIA description for modal | -| **modalId** | `string` | | id attribute for modal | -| **onClose\*** | `() => void` | | Callback fired when the Modal is requested to be closed by a click on the overlay or when user press esc key. | -| **onEscKeyDown\*** | `(event: KeyboardEvent) => void` | | Callback fired when the escape key is pressed. | -| **onOverlayClick\*** | `(event: React.MouseEvent) => void` | | Callback fired when the overlay is clicked. | -| **onAnimationEnd\*** | `() => void` | | Callback fired when the Modal has exited and the animation is finished. | +| Name | Type | Default | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **open\*** | `boolean` | | Control if the modal is open or not. | +| **center** | `boolean` | false | Should the dialog be centered. | +| **closeOnEsc** | `boolean` | true | Is the modal closable when user press esc key. | +| **closeOnOverlayClick** | `boolean` | true | Is the modal closable when user click on overlay. | +| **blockScroll** | `boolean` | true | Whether to block scrolling when dialog is open. | +| **showCloseIcon** | `boolean` | true | Show the close icon. | +| **closeIconId** | `string` | | id attribute for the close icon button. | +| **closeIcon** | `React.ReactNode` | | Custom icon to render (svg, img, etc...). | +| **focusTrapped** | `boolean` | true | When the modal is open, trap focus within it. | +| **focusTrapOptions** | `{ focusOn?: React.RefElement }` | undefined | Options for focus trap. Useful if you want to focus a different element when modal opens. | +| **container** | `Element` | | You can specify a container prop which should be of type `Element`. The portal will be rendered inside that element. The default behavior will create a div node and render it at the at the end of document.body. | +| **classNames** | `{ root?: string; overlay?: string; overlayAnimationIn?: string; overlayAnimationOut?: string; modal?: string; modalAnimationIn?: string; modalAnimationOut?: string; closeButton?: string; closeIcon?: string; }` | | An object containing classNames to style the modal. | +| **styles** | `{ root?: React.CSSProperties; overlay?: React.CSSProperties; overlay?: React.CSSProperties; modalContainer?: React.CSSProperties; modal?: React.CSSProperties; closeButton?: React.CSSProperties; closeIcon?: React.CSSProperties; }` | | An object containing the styles objects to style the modal. | +| **animationDuration** | `number` | 300 | Animation duration in milliseconds. | +| **role** | `string` | "dialog" | ARIA role for modal | +| **ariaLabelledby** | `string` | | ARIA label for modal | +| **ariaDescribedby** | `string` | | ARIA description for modal | +| **modalId** | `string` | | id attribute for modal | +| **onClose\*** | `() => void` | | Callback fired when the Modal is requested to be closed by a click on the overlay or when user press esc key. | +| **onEscKeyDown\*** | `(event: KeyboardEvent) => void` | | Callback fired when the escape key is pressed. | +| **onOverlayClick\*** | `(event: React.MouseEvent) => void` | | Callback fired when the overlay is clicked. | +| **onAnimationEnd\*** | `() => void` | | Callback fired when the Modal has exited and the animation is finished. | ## License diff --git a/website/src/examples/FocusTrappedOnModalRoot.tsx b/website/src/examples/FocusTrappedOptions.tsx similarity index 84% rename from website/src/examples/FocusTrappedOnModalRoot.tsx rename to website/src/examples/FocusTrappedOptions.tsx index 0221afd0..02b499f6 100644 --- a/website/src/examples/FocusTrappedOnModalRoot.tsx +++ b/website/src/examples/FocusTrappedOptions.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Modal } from 'react-responsive-modal'; const App = () => { const [open, setOpen] = React.useState(false); + const modalRef = useRef(null); return ( <> @@ -11,9 +12,10 @@ const App = () => { setOpen(false)} - focusTrapOptions={{ focusOn: 'modalRoot' }} + focusTrapOptions={{ focusOn: modalRef }} >

Try tabbing/shift-tabbing thru elements

diff --git a/yarn.lock b/yarn.lock index f203bff4..0ee7e734 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1664,6 +1664,26 @@ __metadata: languageName: node linkType: hard +"@bedrock-layout/use-forwarded-ref@npm:^1.1.4": + version: 1.1.4 + resolution: "@bedrock-layout/use-forwarded-ref@npm:1.1.4" + dependencies: + "@bedrock-layout/use-stateful-ref": ^1.1.4 + peerDependencies: + react: ">=16.8" + checksum: 75c4fc1cae05ac03dd6ef86e05eb025f120a4fe32773eab63fede8eeb471664b1dd6d5638b8cbb3ee1619d6cab08e24912b0fa8ca33c8bfe87ed699f5e28f8e2 + languageName: node + linkType: hard + +"@bedrock-layout/use-stateful-ref@npm:^1.1.4": + version: 1.1.4 + resolution: "@bedrock-layout/use-stateful-ref@npm:1.1.4" + peerDependencies: + react: ">=16.8" + checksum: 3842ebfd9302f9bf271388fd56183d2697d4211e72ac7eab8b2069831a85375e53135ee10e7c008057324bd13307cea7cc551de289488af7bb910a9c99d11ed5 + languageName: node + linkType: hard + "@cnakazawa/watch@npm:^1.0.3": version: 1.0.4 resolution: "@cnakazawa/watch@npm:1.0.4" @@ -12499,6 +12519,7 @@ fsevents@^1.2.7: version: 0.0.0-use.local resolution: "react-responsive-modal@workspace:react-responsive-modal" dependencies: + "@bedrock-layout/use-forwarded-ref": ^1.1.4 "@size-limit/preset-small-lib": 4.7.0 "@testing-library/jest-dom": 5.11.6 "@testing-library/react": 11.1.2