mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-03 22:29:01 +02:00
Add focus trap to Modal component (#28754)
* Add focus-trap-react npm package * Trap the focus for the Modal * In some cases, the focus will not return to the trigger element * If there are no tabbable elements, the focus should fallback * Add explanations for focusTrapOptions props and extend test * Auto generate package-lock.json - Add focus-trap-react npm package GitOrigin-RevId: 488a05d5e95dd369c69bedcfaf7c1fd5e456e302
This commit is contained in:
Generated
+29
-5
@@ -21929,7 +21929,6 @@
|
||||
"version": "18.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz",
|
||||
"integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -33597,6 +33596,31 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.6.6",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz",
|
||||
"integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap-react": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-11.0.4.tgz",
|
||||
"integrity": "sha512-tC7jC/yqeAqhe4irNIzdyDf9XCtGSeECHiBSYJBO/vIN0asizbKZCt8TarB6/XqIceu42ajQ/U4lQJ9pZlWjrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"focus-trap": "^7.6.5",
|
||||
"tabbable": "^6.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||
@@ -51963,10 +51987,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"dev": true,
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
|
||||
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/table": {
|
||||
@@ -58520,6 +58543,7 @@
|
||||
"express-http-proxy": "^1.6.0",
|
||||
"express-session": "^1.17.1",
|
||||
"file-type": "^21.0.0",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"globby": "^5.0.0",
|
||||
"helmet": "^6.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
+6
-1
@@ -273,7 +273,12 @@ const FigureModalContent = () => {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<OLModal onHide={hide} className="figure-modal" show>
|
||||
<OLModal
|
||||
onHide={hide}
|
||||
className="figure-modal"
|
||||
show
|
||||
returnFocusOnDeactivate={false}
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>
|
||||
{helpShown
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
FocusTrap as FocusTrapReact,
|
||||
FocusTrapProps as FocusTrapReactProps,
|
||||
} from 'focus-trap-react'
|
||||
|
||||
export type FocusTrapProps = {
|
||||
active: FocusTrapReactProps['active']
|
||||
children: React.ReactNode
|
||||
focusTrapOptions?: FocusTrapReactProps['focusTrapOptions']
|
||||
}
|
||||
|
||||
export default function FocusTrap({
|
||||
active,
|
||||
focusTrapOptions,
|
||||
children,
|
||||
}: FocusTrapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<FocusTrapReact
|
||||
active={active}
|
||||
focusTrapOptions={{
|
||||
...focusTrapOptions,
|
||||
fallbackFocus: () => containerRef.current as HTMLElement,
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef}>{children}</div>
|
||||
</FocusTrapReact>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import FocusTrap from '../focus-trap'
|
||||
import {
|
||||
Modal,
|
||||
ModalProps,
|
||||
@@ -6,18 +7,44 @@ import {
|
||||
ModalFooterProps,
|
||||
} from 'react-bootstrap'
|
||||
import { ModalBodyProps } from 'react-bootstrap/ModalBody'
|
||||
import type { Options as FocusTrapOptions } from 'focus-trap'
|
||||
|
||||
type OLModalProps = ModalProps & {
|
||||
size?: 'sm' | 'lg'
|
||||
onHide: () => void
|
||||
}
|
||||
show?: boolean
|
||||
} & Pick<
|
||||
FocusTrapOptions,
|
||||
'escapeDeactivates' | 'clickOutsideDeactivates' | 'returnFocusOnDeactivate'
|
||||
>
|
||||
|
||||
type OLModalHeaderProps = ModalHeaderProps & {
|
||||
closeButton?: boolean
|
||||
}
|
||||
|
||||
export function OLModal({ children, ...props }: OLModalProps) {
|
||||
return <Modal {...props}>{children}</Modal>
|
||||
export function OLModal({
|
||||
children,
|
||||
show = false,
|
||||
onHide,
|
||||
returnFocusOnDeactivate = true, // Return focus to trigger element when modal closes
|
||||
escapeDeactivates = false, // Let React-Bootstrap Modal handle Escape key to avoid double Escape key handling
|
||||
clickOutsideDeactivates = true, // Allow focus trap to deactivate on outside click and let React-Bootstrap Modal handle it
|
||||
...props
|
||||
}: OLModalProps) {
|
||||
return (
|
||||
<Modal show={show} onHide={onHide} {...props}>
|
||||
<FocusTrap
|
||||
active={show}
|
||||
focusTrapOptions={{
|
||||
escapeDeactivates,
|
||||
clickOutsideDeactivates,
|
||||
returnFocusOnDeactivate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FocusTrap>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function OLModalHeader({
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"express-http-proxy": "^1.6.0",
|
||||
"express-session": "^1.17.1",
|
||||
"file-type": "^21.0.0",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"globby": "^5.0.0",
|
||||
"helmet": "^6.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalHeader,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
|
||||
function Modal({ backdrop }: { backdrop?: boolean | 'static' } = {}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsModalOpen(true)}>Open modal</button>
|
||||
|
||||
<OLModal
|
||||
show={isModalOpen}
|
||||
onHide={() => setIsModalOpen(false)}
|
||||
backdrop={backdrop}
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>This is a focus trap modal</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
<p>This is for testing the modal behaviour</p>
|
||||
<label htmlFor="modal-input">Enter text: </label>
|
||||
<input id="modal-input" />
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<button onClick={() => setIsModalOpen(false)}>Close the modal</button>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<OLModal />', function () {
|
||||
it('dismisses on single Escape key press', function () {
|
||||
cy.mount(<Modal />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).should('be.visible')
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.findByLabelText(/enter text/i).should('be.visible')
|
||||
cy.get('body').type('{esc}')
|
||||
// Modal should hide with single escape (escapeDeactivates: false means FocusTrap doesn't handle it)
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Open modal' }).should('be.visible')
|
||||
})
|
||||
|
||||
it('dismisses when clicking outside of the modal (backdrop: true)', function () {
|
||||
cy.mount(<Modal />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.get('body').click(0, 0)
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not dismiss when clicking outside of the modal (backdrop: "static")', function () {
|
||||
cy.mount(<Modal backdrop="static" />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.get('body').click(0, 0)
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
})
|
||||
|
||||
it('traps focus within modal', function () {
|
||||
cy.mount(<Modal />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
|
||||
cy.findByRole('button', { name: 'Close' }).should('be.focused')
|
||||
cy.focused().tab()
|
||||
cy.findByLabelText(/enter text/i).should('be.focused')
|
||||
cy.focused().tab()
|
||||
cy.findByRole('button', { name: 'Close the modal' }).should('be.focused')
|
||||
cy.focused().tab()
|
||||
cy.findByRole('button', { name: 'Close' }).should('be.focused')
|
||||
cy.focused().tab({ shift: true })
|
||||
cy.findByRole('button', { name: 'Close the modal' }).should('be.focused')
|
||||
})
|
||||
|
||||
it('restores focus to trigger button after modal closes', function () {
|
||||
cy.mount(<Modal />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.findByRole('button', { name: 'Close the modal' }).click()
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
cy.findByRole('button', { name: 'Open modal' }).should('be.focused')
|
||||
cy.focused().should('not.match', 'body')
|
||||
})
|
||||
|
||||
it('closes modal when clicking close button (X)', function () {
|
||||
cy.mount(<Modal />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.findByRole('button', { name: 'Close' }).click()
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
|
||||
it('dismisses on Escape key with backdrop="static"', function () {
|
||||
cy.mount(<Modal backdrop="static" />)
|
||||
cy.findByRole('button', { name: 'Open modal' }).click()
|
||||
cy.findByRole('dialog').should('be.visible')
|
||||
cy.get('body').type('{esc}')
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user