Sfoglia il codice sorgente

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
Rebeka Dekany 8 mesi fa
parent
commit
b773ac2715

+ 29 - 5
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",

+ 6 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx

@@ -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

+ 31 - 0
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<HTMLDivElement>(null)
+
+  return (
+    <FocusTrapReact
+      active={active}
+      focusTrapOptions={{
+        ...focusTrapOptions,
+        fallbackFocus: () => containerRef.current as HTMLElement,
+      }}
+    >
+      <div ref={containerRef}>{children}</div>
+    </FocusTrapReact>
+  )
+}

+ 30 - 3
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 <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({

+ 1 - 0
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",

+ 109 - 0
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 (
+    <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:&nbsp;</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')
+  })
+})