diff --git a/package-lock.json b/package-lock.json index 6191128e26..087e17463b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx index 9fbb37e344..477c2e7447 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx @@ -273,7 +273,12 @@ const FigureModalContent = () => { return null } return ( - + {helpShown diff --git a/services/web/frontend/js/shared/components/focus-trap.tsx b/services/web/frontend/js/shared/components/focus-trap.tsx new file mode 100644 index 0000000000..d5def1efab --- /dev/null +++ b/services/web/frontend/js/shared/components/focus-trap.tsx @@ -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(null) + + return ( + containerRef.current as HTMLElement, + }} + > +
{children}
+
+ ) +} diff --git a/services/web/frontend/js/shared/components/ol/ol-modal.tsx b/services/web/frontend/js/shared/components/ol/ol-modal.tsx index 013536fc09..5342946079 100644 --- a/services/web/frontend/js/shared/components/ol/ol-modal.tsx +++ b/services/web/frontend/js/shared/components/ol/ol-modal.tsx @@ -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 {children} +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 ( + + + {children} + + + ) } export function OLModalHeader({ diff --git a/services/web/package.json b/services/web/package.json index ff9b68091e..ec54987e7c 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -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", diff --git a/services/web/test/frontend/components/shared/modal.spec.tsx b/services/web/test/frontend/components/shared/modal.spec.tsx new file mode 100644 index 0000000000..613b711f27 --- /dev/null +++ b/services/web/test/frontend/components/shared/modal.spec.tsx @@ -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 ( +
+ + + setIsModalOpen(false)} + backdrop={backdrop} + > + + This is a focus trap modal + + +

This is for testing the modal behaviour

+ + +
+ + + +
+
+ ) +} + +describe('', function () { + it('dismisses on single Escape key press', function () { + cy.mount() + 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() + 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() + 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() + 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() + 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() + 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() + cy.findByRole('button', { name: 'Open modal' }).click() + cy.findByRole('dialog').should('be.visible') + cy.get('body').type('{esc}') + cy.findByRole('dialog').should('not.exist') + }) +})