From 3ee93006df6991e9d643136059901f7d8ade5eda Mon Sep 17 00:00:00 2001 From: Amadeusz Starzykiewicz Date: Wed, 12 May 2021 12:46:20 +0100 Subject: [PATCH 1/5] Add options for FocusTrap to allow better behavior on modal open --- react-responsive-modal/src/FocusTrap.tsx | 24 +++++++++--- react-responsive-modal/src/index.tsx | 18 +++++++-- website/src/docs/index.mdx | 47 ++++++++++++------------ 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/react-responsive-modal/src/FocusTrap.tsx b/react-responsive-modal/src/FocusTrap.tsx index bacac9fe..ccfc7b4b 100644 --- a/react-responsive-modal/src/FocusTrap.tsx +++ b/react-responsive-modal/src/FocusTrap.tsx @@ -6,11 +6,16 @@ import { getAllTabbingElements, } from './lib/focusTrapJs'; +export interface FocusTrapOptions { + focusOn?: 'firstFocusableElement' | 'modalRoot'; +} + interface FocusTrapProps { container?: React.RefObject | null; + options?: FocusTrapOptions; } -export const FocusTrap = ({ container }: FocusTrapProps) => { +export const FocusTrap = ({ container, options }: FocusTrapProps) => { const refLastFocus = useRef(); /** * Handle focus lock on the modal @@ -27,8 +32,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 +42,17 @@ export const FocusTrap = ({ container }: FocusTrapProps) => { ) { refLastFocus.current = document.activeElement as HTMLElement; } - allTabbingElements[0].focus(); + }; + + if (options?.focusOn === 'firstFocusableElement') { + const allTabbingElements = getAllTabbingElements(container.current); + if (allTabbingElements[0]) { + savePreviousFocus(); + allTabbingElements[0].focus(); + } + } else if (options?.focusOn === 'modalRoot') { + savePreviousFocus(); + container?.current?.focus(); } } return () => { @@ -48,7 +62,7 @@ export const FocusTrap = ({ container }: FocusTrapProps) => { refLastFocus.current?.focus(); } }; - }, [container]); + }, [container, options?.focusOn]); return null; }; diff --git a/react-responsive-modal/src/index.tsx b/react-responsive-modal/src/index.tsx index 3f5e5482..a4fe25e5 100644 --- a/react-responsive-modal/src/index.tsx +++ b/react-responsive-modal/src/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import ReactDom from 'react-dom'; import cx from 'classnames'; import CloseIcon from './CloseIcon'; -import { FocusTrap } from './FocusTrap'; +import { FocusTrap, FocusTrapOptions } from './FocusTrap'; import { modalManager, useModalManager } from './modalManager'; import { useScrollLock } from './useScrollLock'; import { isBrowser } from './utils'; @@ -69,6 +69,12 @@ export interface ModalProps { * Default to true. */ focusTrapped?: boolean; + /** + * Options focus trapping. + * + * Default to { focusOn: 'firstFocusableElement' }. + */ + focusTrapOptions?: FocusTrapOptions; /** * You can specify a container prop which should be of type `Element`. * The portal will be rendered inside that element. @@ -104,7 +110,7 @@ export interface ModalProps { /** * Animation duration in milliseconds. * - * Default to 500. + * Default to 300. */ animationDuration?: number; /** @@ -157,6 +163,7 @@ export const Modal = ({ closeIconId, closeIcon, focusTrapped = true, + focusTrapOptions = { focusOn: 'firstFocusableElement' }, animationDuration = 300, classNames, styles, @@ -171,6 +178,7 @@ export const Modal = ({ children, }: ModalProps) => { const refModal = useRef(null); + const refDialog = useRef(null); const refShouldClose = useRef(null); const refContainer = useRef(null); // Lazily create the ref instance @@ -314,6 +322,7 @@ export const Modal = ({ onClick={handleClickOverlay} >
- {focusTrapped && } + {focusTrapped && ( + + )} {children} {showCloseIcon && ( 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?: '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. | ## License From bf4588aff790b187cacd2c6c702b5aa5f6ab6096 Mon Sep 17 00:00:00 2001 From: Amadeusz Starzykiewicz Date: Thu, 20 May 2021 12:25:23 +0200 Subject: [PATCH 2/5] Bump size limit due to additional functionality --- react-responsive-modal/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/react-responsive-modal/package.json b/react-responsive-modal/package.json index 36cfe643..56fc2c70 100644 --- a/react-responsive-modal/package.json +++ b/react-responsive-modal/package.json @@ -47,11 +47,11 @@ "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": { From 32c976c261b54dbffc4642c0e06d3d523196cb5f Mon Sep 17 00:00:00 2001 From: Amadeusz Starzykiewicz Date: Mon, 17 May 2021 13:47:36 +0200 Subject: [PATCH 3/5] Add test cases for focus trap options --- .../__tests__/index.test.tsx | 2 +- .../cypress/integration/modal.spec.ts | 10 +++++ website/src/components/ExampleRendered.tsx | 2 + website/src/docs/index.mdx | 10 +++++ website/src/examples/FocusTrapped.tsx | 2 +- .../src/examples/FocusTrappedOnModalRoot.tsx | 40 +++++++++++++++++++ 6 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 website/src/examples/FocusTrappedOnModalRoot.tsx 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/website/src/components/ExampleRendered.tsx b/website/src/components/ExampleRendered.tsx index 461bcb81..8d77f4c9 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 FocusTrappedOnModalRoot from '../examples/FocusTrappedOnModalRoot'; 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, + focusTrappedOnModalRoot: FocusTrappedOnModalRoot, customCssStyle: CustomCssStyle, customAnimation: CustomAnimation, customCloseIcon: CustomCloseIcon, diff --git a/website/src/docs/index.mdx b/website/src/docs/index.mdx index 5f900ae4..9738299e 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 on modal root + +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`. + + + +```js file=../examples/FocusTrappedOnModalRoot.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: 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/FocusTrappedOnModalRoot.tsx b/website/src/examples/FocusTrappedOnModalRoot.tsx new file mode 100644 index 00000000..0221afd0 --- /dev/null +++ b/website/src/examples/FocusTrappedOnModalRoot.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Modal } from 'react-responsive-modal'; + +const App = () => { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + setOpen(false)} + focusTrapOptions={{ focusOn: 'modalRoot' }} + > +

Try tabbing/shift-tabbing thru elements

+ +

+ +

+

+ +

+ + + +
+ + ); +}; + +export default App; From cf03695273f3164b9c10632a277b2f6b698500d1 Mon Sep 17 00:00:00 2001 From: Amadeusz Starzykiewicz Date: Fri, 28 May 2021 16:26:12 +0200 Subject: [PATCH 4/5] Add option to focus on any element - including modal root --- react-responsive-modal/package.json | 1 + react-responsive-modal/src/FocusTrap.tsx | 13 +- react-responsive-modal/src/index.tsx | 368 +++++++++--------- website/src/components/ExampleRendered.tsx | 4 +- website/src/docs/index.mdx | 56 +-- ...nModalRoot.tsx => FocusTrappedOptions.tsx} | 6 +- yarn.lock | 21 + 7 files changed, 251 insertions(+), 218 deletions(-) rename website/src/examples/{FocusTrappedOnModalRoot.tsx => FocusTrappedOptions.tsx} (84%) 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 From 02cbb04afd0af90f70ea0e03dd88e049660adf35 Mon Sep 17 00:00:00 2001 From: Amadeusz Starzykiewicz Date: Tue, 1 Jun 2021 14:06:18 +0200 Subject: [PATCH 5/5] Simplify and rename focusTrapOptions to initialFocusRef --- react-responsive-modal/src/FocusTrap.tsx | 14 +++++--------- react-responsive-modal/src/index.tsx | 13 ++++++++----- website/src/components/ExampleRendered.tsx | 4 ++-- website/src/docs/index.mdx | 11 ++++++----- ...pedOptions.tsx => FocusTrappedInitialFocus.tsx} | 4 ++-- 5 files changed, 23 insertions(+), 23 deletions(-) rename website/src/examples/{FocusTrappedOptions.tsx => FocusTrappedInitialFocus.tsx} (90%) diff --git a/react-responsive-modal/src/FocusTrap.tsx b/react-responsive-modal/src/FocusTrap.tsx index 3f4f7735..9594c604 100644 --- a/react-responsive-modal/src/FocusTrap.tsx +++ b/react-responsive-modal/src/FocusTrap.tsx @@ -6,16 +6,12 @@ import { getAllTabbingElements, } from './lib/focusTrapJs'; -export interface FocusTrapOptions { - focusOn?: React.RefObject; -} - interface FocusTrapProps { container?: React.RefObject | null; - options?: FocusTrapOptions; + initialFocusRef?: React.RefObject; } -export const FocusTrap = ({ container, options }: FocusTrapProps) => { +export const FocusTrap = ({ container, initialFocusRef }: FocusTrapProps) => { const refLastFocus = useRef(); /** * Handle focus lock on the modal @@ -44,11 +40,11 @@ export const FocusTrap = ({ container, options }: FocusTrapProps) => { } }; - if (options?.focusOn) { + if (initialFocusRef) { savePreviousFocus(); // We need to schedule focusing on a next frame - this allows to focus on the modal root requestAnimationFrame(() => { - options.focusOn?.current?.focus(); + initialFocusRef.current?.focus(); }); } else { const allTabbingElements = getAllTabbingElements(container.current); @@ -65,7 +61,7 @@ export const FocusTrap = ({ container, options }: FocusTrapProps) => { refLastFocus.current?.focus(); } }; - }, [container, options?.focusOn]); + }, [container, initialFocusRef]); return null; }; diff --git a/react-responsive-modal/src/index.tsx b/react-responsive-modal/src/index.tsx index d2425b98..b06ddc41 100644 --- a/react-responsive-modal/src/index.tsx +++ b/react-responsive-modal/src/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import ReactDom from 'react-dom'; import cx from 'classnames'; import CloseIcon from './CloseIcon'; -import { FocusTrap, FocusTrapOptions } from './FocusTrap'; +import { FocusTrap } from './FocusTrap'; import { modalManager, useModalManager } from './modalManager'; import { useScrollLock } from './useScrollLock'; import { isBrowser } from './utils'; @@ -71,11 +71,11 @@ export interface ModalProps { */ focusTrapped?: boolean; /** - * Options for focus trapping. + * Element to focus when focus trap is used. * * Default to undefined. */ - focusTrapOptions?: FocusTrapOptions; + initialFocusRef?: React.RefObject; /** * You can specify a container prop which should be of type `Element`. * The portal will be rendered inside that element. @@ -166,7 +166,7 @@ export const Modal = React.forwardRef( closeIconId, closeIcon, focusTrapped = true, - focusTrapOptions = undefined, + initialFocusRef = undefined, animationDuration = 300, classNames, styles, @@ -346,7 +346,10 @@ export const Modal = React.forwardRef( tabIndex={-1} > {focusTrapped && ( - + )} {children} {showCloseIcon && ( diff --git a/website/src/components/ExampleRendered.tsx b/website/src/components/ExampleRendered.tsx index 78d1d7ea..1f6086a3 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 FocusTrappedOptions from '../examples/FocusTrappedOptions'; +import FocusTrappedInitialFocus from '../examples/FocusTrappedInitialFocus'; 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, - focusTrappedOptions: FocusTrappedOptions, + focusTrappedInitialFocus: FocusTrappedInitialFocus, customCssStyle: CustomCssStyle, customAnimation: CustomAnimation, customCloseIcon: CustomCloseIcon, diff --git a/website/src/docs/index.mdx b/website/src/docs/index.mdx index c76b039b..f27673e1 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 options +### 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 `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. +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/FocusTrappedOptions.tsx +```js file=../examples/FocusTrappedInitialFocus.tsx ``` @@ -190,12 +190,13 @@ By default, the Modal will be rendered at the end of the html body tag. If you w | **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. | +| **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 | diff --git a/website/src/examples/FocusTrappedOptions.tsx b/website/src/examples/FocusTrappedInitialFocus.tsx similarity index 90% rename from website/src/examples/FocusTrappedOptions.tsx rename to website/src/examples/FocusTrappedInitialFocus.tsx index 02b499f6..b48abeb1 100644 --- a/website/src/examples/FocusTrappedOptions.tsx +++ b/website/src/examples/FocusTrappedInitialFocus.tsx @@ -3,7 +3,7 @@ import { Modal } from 'react-responsive-modal'; const App = () => { const [open, setOpen] = React.useState(false); - const modalRef = useRef(null); + const modalRef = useRef(null); return ( <> @@ -15,7 +15,7 @@ const App = () => { ref={modalRef} open={open} onClose={() => setOpen(false)} - focusTrapOptions={{ focusOn: modalRef }} + initialFocusRef={modalRef} >

Try tabbing/shift-tabbing thru elements