diff --git a/react-responsive-modal/__tests__/index.test.tsx b/react-responsive-modal/__tests__/index.test.tsx index a2021677..ffd8a4f3 100644 --- a/react-responsive-modal/__tests__/index.test.tsx +++ b/react-responsive-modal/__tests__/index.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { Modal } from '../src'; describe('modal', () => { diff --git a/react-responsive-modal/cypress/integration/modal.spec.ts b/react-responsive-modal/cypress/integration/modal.spec.ts index 89a00076..efe6f534 100644 --- a/react-responsive-modal/cypress/integration/modal.spec.ts +++ b/react-responsive-modal/cypress/integration/modal.spec.ts @@ -65,4 +65,14 @@ describe('simple modal', () => { cy.get('[data-testid=modal]').should('not.exist'); cy.get('body').should('not.have.css', 'overflow', 'hidden'); }); + + it('should focus first element within modal', () => { + cy.get('button').eq(3).click(); + cy.get('[data-testid=modal] input').first().should('have.focus'); + }); + + it('should focus on modal root', () => { + cy.get('button').eq(4).click(); + cy.get('[data-testid=modal]').should('have.focus'); + }); }); diff --git a/react-responsive-modal/package.json b/react-responsive-modal/package.json index 36cfe643..1ffe3e3c 100644 --- a/react-responsive-modal/package.json +++ b/react-responsive-modal/package.json @@ -47,14 +47,15 @@ "size-limit": [ { "path": "dist/react-responsive-modal.cjs.production.min.js", - "limit": "3.8 KB" + "limit": "4.0 KB" }, { "path": "dist/react-responsive-modal.esm.js", - "limit": "3.8 KB" + "limit": "4.0 KB" } ], "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 bacac9fe..9594c604 100644 --- a/react-responsive-modal/src/FocusTrap.tsx +++ b/react-responsive-modal/src/FocusTrap.tsx @@ -8,9 +8,10 @@ import { interface FocusTrapProps { container?: React.RefObject | null; + initialFocusRef?: React.RefObject; } -export const FocusTrap = ({ container }: FocusTrapProps) => { +export const FocusTrap = ({ container, initialFocusRef }: FocusTrapProps) => { const refLastFocus = useRef(); /** * Handle focus lock on the modal @@ -27,8 +28,7 @@ export const FocusTrap = ({ container }: FocusTrapProps) => { } // On mount we focus on the first focusable element in the modal if there is one if (isBrowser && container?.current) { - const allTabbingElements = getAllTabbingElements(container.current); - if (allTabbingElements[0]) { + const savePreviousFocus = () => { // First we save the last focused element // only if it's a focusable element if ( @@ -38,7 +38,20 @@ export const FocusTrap = ({ container }: FocusTrapProps) => { ) { refLastFocus.current = document.activeElement as HTMLElement; } - allTabbingElements[0].focus(); + }; + + if (initialFocusRef) { + savePreviousFocus(); + // We need to schedule focusing on a next frame - this allows to focus on the modal root + requestAnimationFrame(() => { + initialFocusRef.current?.focus(); + }); + } else { + const allTabbingElements = getAllTabbingElements(container.current); + if (allTabbingElements[0]) { + savePreviousFocus(); + allTabbingElements[0].focus(); + } } } return () => { @@ -48,7 +61,7 @@ export const FocusTrap = ({ container }: FocusTrapProps) => { refLastFocus.current?.focus(); } }; - }, [container]); + }, [container, initialFocusRef]); return null; }; diff --git a/react-responsive-modal/src/index.tsx b/react-responsive-modal/src/index.tsx index 3f5e5482..b06ddc41 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 } 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', @@ -69,6 +70,12 @@ export interface ModalProps { * Default to true. */ focusTrapped?: boolean; + /** + * Element to focus when focus trap is used. + * + * Default to undefined. + */ + initialFocusRef?: React.RefObject; /** * You can specify a container prop which should be of type `Element`. * The portal will be rendered inside that element. @@ -104,7 +111,7 @@ export interface ModalProps { /** * Animation duration in milliseconds. * - * Default to 500. + * Default to 300. */ animationDuration?: number; /** @@ -146,208 +153,222 @@ export interface ModalProps { children?: React.ReactNode; } -export const Modal = ({ - open, - center, - blockScroll = true, - closeOnEsc = true, - closeOnOverlayClick = true, - container, - showCloseIcon = true, - closeIconId, - closeIcon, - focusTrapped = true, - animationDuration = 300, - classNames, - styles, - role = 'dialog', - ariaDescribedby, - ariaLabelledby, - modalId, - onClose, - onEscKeyDown, - onOverlayClick, - onAnimationEnd, - children, -}: ModalProps) => { - 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'); - } - - // 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, + initialFocusRef = 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); + } - const containerModal = container || refContainer.current; + onAnimationEnd?.(); + }; - const overlayAnimation = open - ? classNames?.overlayAnimationIn ?? classes.overlayAnimationIn - : classNames?.overlayAnimationOut ?? classes.overlayAnimationOut; + const containerModal = container || refContainer.current; - const modalAnimation = open - ? classNames?.modalAnimationIn ?? classes.modalAnimationIn - : classNames?.modalAnimationOut ?? classes.modalAnimationOut; + const overlayAnimation = open + ? classNames?.overlayAnimationIn ?? classes.overlayAnimationIn + : classNames?.overlayAnimationOut ?? classes.overlayAnimationOut; - return showPortal && containerModal - ? ReactDom.createPortal( -
-
+ const modalAnimation = open + ? classNames?.modalAnimationIn ?? classes.modalAnimationIn + : classNames?.modalAnimationOut ?? classes.modalAnimationOut; + + 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 461bcb81..1f6086a3 100644 --- a/website/src/components/ExampleRendered.tsx +++ b/website/src/components/ExampleRendered.tsx @@ -2,6 +2,7 @@ import Simple from '../examples/Simple'; import ExampleMultiple from '../examples/Multiple'; import LongContent from '../examples/LongContent'; import FocusTrapped from '../examples/FocusTrapped'; +import FocusTrappedInitialFocus from '../examples/FocusTrappedInitialFocus'; import CustomCssStyle from '../examples/CustomCssStyle'; import CustomAnimation from '../examples/CustomAnimation'; import CustomCloseIcon from '../examples/CustomCloseIcon'; @@ -12,6 +13,7 @@ const examples: Record JSX.Element> = { multiple: ExampleMultiple, longContent: LongContent, focusTrapped: FocusTrapped, + focusTrappedInitialFocus: FocusTrappedInitialFocus, customCssStyle: CustomCssStyle, customAnimation: CustomAnimation, customCloseIcon: CustomCloseIcon, diff --git a/website/src/docs/index.mdx b/website/src/docs/index.mdx index a9b3de01..f27673e1 100644 --- a/website/src/docs/index.mdx +++ b/website/src/docs/index.mdx @@ -93,6 +93,16 @@ If you want to disable this behavior, set the `focusTrapped` prop to `false`. ``` +### Focus Trapped initial focus + +You can also set to trap focus within the modal, but decide where to put focus when opened. To do this use `initialFocusRef` prop and set it to a ref of an element you want to focus. In this example we focus on the modal root element. + + + +```js file=../examples/FocusTrappedInitialFocus.tsx + +``` + ### Custom styling with css Customising the Modal style via css is really easy. For example if you add the following css to your app you will get the following result: @@ -169,29 +179,31 @@ 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. | -| **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. | +| **initialFocusRef** | `React.RefElement` | undefined | Sets focus on this specific element when modal opens if focus trap is used. | +| **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 | +| **ref** | `React.RefElement` | undefined | Ref for modal dialog element | +| **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/FocusTrapped.tsx b/website/src/examples/FocusTrapped.tsx index 10d79fca..122ede2e 100644 --- a/website/src/examples/FocusTrapped.tsx +++ b/website/src/examples/FocusTrapped.tsx @@ -15,7 +15,7 @@ const App = () => {

diff --git a/website/src/examples/FocusTrappedInitialFocus.tsx b/website/src/examples/FocusTrappedInitialFocus.tsx new file mode 100644 index 00000000..b48abeb1 --- /dev/null +++ b/website/src/examples/FocusTrappedInitialFocus.tsx @@ -0,0 +1,42 @@ +import React, { useRef } from 'react'; +import { Modal } from 'react-responsive-modal'; + +const App = () => { + const [open, setOpen] = React.useState(false); + const modalRef = useRef(null); + + return ( + <> + + + setOpen(false)} + initialFocusRef={modalRef} + > +

Try tabbing/shift-tabbing thru elements

+ +

+ +

+

+ +

+ + + +
+ + ); +}; + +export default App; 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