Explorar o código

Make editor popover toolbar keyboard focusable (#25169)

* Remove redundant class conflicting with focus styling

* Make the toolbar in the popover focusable via keyboard

* Focus to the first context menu item via keyboard only

GitOrigin-RevId: 7d3e2af4ba96654b5b2312b3999483c2a439b406
Rebeka Dekany hai 1 ano
pai
achega
2731ffaf10

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -1025,6 +1025,7 @@
   "more_collabs_per_project": "",
   "more_comments": "",
   "more_compile_time": "",
+  "more_editor_toolbar_item": "",
   "more_info": "",
   "more_options": "",
   "more_options_for_border_settings_coming_soon": "",

+ 40 - 9
services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from 'react'
+import React, { useCallback, useEffect, useRef } from 'react'
 import ReactDOM from 'react-dom'
 import {
   Dropdown,
@@ -13,6 +13,7 @@ function FileTreeContextMenu() {
   const { fileTreeReadOnly } = useFileTreeData()
   const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
   const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
+  const keyboardInputRef = useRef(false)
 
   useEffect(() => {
     if (contextMenuCoords) {
@@ -22,12 +23,24 @@ function FileTreeContextMenu() {
     }
   }, [contextMenuCoords])
 
-  if (!contextMenuCoords || fileTreeReadOnly) return null
+  useEffect(() => {
+    if (contextMenuCoords && keyboardInputRef.current) {
+      const firstDropdownMenuItem = document.querySelector(
+        '#dropdown-file-tree-context-menu .dropdown-item:not([disabled])'
+      ) as HTMLButtonElement | null
+
+      if (firstDropdownMenuItem) {
+        firstDropdownMenuItem.focus()
+      }
+    }
+  }, [contextMenuCoords])
 
   function close() {
+    if (!contextMenuCoords) return
     setContextMenuCoords(null)
+
     if (toggleButtonRef.current) {
-      // A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key
+      // A11y - Focus moves back to the trigger button when the context menu is dismissed
       toggleButtonRef.current.focus()
     }
   }
@@ -36,14 +49,33 @@ function FileTreeContextMenu() {
     if (!wantOpen) close()
   }
 
-  // A11y - Close the context menu when the user presses the Tab key
-  // Focus should move to the next element in the filetree
-  function handleKeyDown(event: React.KeyboardEvent<Element>) {
-    if (event.key === 'Tab') {
+  function handleClose(event: React.KeyboardEvent<Element>) {
+    if (event.key === 'Tab' || event.key === 'Escape') {
+      event.preventDefault()
       close()
     }
   }
 
+  const handleKeyDown = useCallback(() => {
+    keyboardInputRef.current = true
+  }, [])
+
+  const handleMouseDown = useCallback(() => {
+    keyboardInputRef.current = false
+  }, [])
+
+  useEffect(() => {
+    document.addEventListener('keydown', handleKeyDown)
+    document.addEventListener('mousedown', handleMouseDown)
+
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown)
+      document.removeEventListener('mousedown', handleMouseDown)
+    }
+  }, [handleKeyDown, handleMouseDown])
+
+  if (!contextMenuCoords || fileTreeReadOnly) return null
+
   return ReactDOM.createPortal(
     <div style={contextMenuCoords} className="context-menu">
       <Dropdown
@@ -54,8 +86,7 @@ function FileTreeContextMenu() {
             ? 'up'
             : 'down'
         }
-        focusFirstItemOnShow // A11y - Focus the first item in the context menu when it opens since the menu is rendered at the root level
-        onKeyDown={handleKeyDown}
+        onKeyDown={handleClose}
         onToggle={handleToggle}
       >
         <DropdownMenu

+ 1 - 1
services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx

@@ -21,7 +21,7 @@ export const ToolbarButtonMenu: FC<{
   const button = (
     <button
       type="button"
-      className="ol-cm-toolbar-button btn"
+      className="ol-cm-toolbar-button"
       aria-label={label}
       onMouseDown={event => {
         event.preventDefault()

+ 55 - 4
services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx

@@ -1,4 +1,5 @@
-import { FC, useRef } from 'react'
+import { FC, useCallback, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
 import classnames from 'classnames'
 import MaterialIcon from '@/shared/components/material-icon'
 import { useCodeMirrorViewContext } from '../codemirror-context'
@@ -11,7 +12,10 @@ export const ToolbarOverflow: FC<{
   setOverflowOpen: (open: boolean) => void
   overflowRef?: React.Ref<HTMLDivElement>
 }> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => {
+  const { t } = useTranslation()
+
   const buttonRef = useRef<HTMLButtonElement>(null)
+  const keyboardInputRef = useRef(false)
   const view = useCodeMirrorViewContext()
 
   const className = classnames(
@@ -22,6 +26,46 @@ export const ToolbarOverflow: FC<{
     }
   )
 
+  // A11y - Move the focus inside the popover to the first toolbar button when it opens
+  const handlePopoverFocus = useCallback(() => {
+    if (keyboardInputRef.current) {
+      const firstToolbarItem = document.querySelector(
+        '#popover-toolbar-overflow .ol-cm-toolbar-overflow button:not([disabled])'
+      ) as HTMLButtonElement | null
+
+      if (firstToolbarItem) {
+        firstToolbarItem.focus()
+      }
+    }
+  }, [])
+
+  const handleKeyDown = useCallback(() => {
+    keyboardInputRef.current = true
+  }, [])
+
+  const handleMouseDown = useCallback(() => {
+    keyboardInputRef.current = false
+  }, [])
+
+  useEffect(() => {
+    document.addEventListener('keydown', handleKeyDown)
+    document.addEventListener('mousedown', handleMouseDown)
+
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown)
+      document.removeEventListener('mousedown', handleMouseDown)
+    }
+  }, [handleKeyDown, handleMouseDown])
+
+  // A11y - Move the focus back to the trigger when the popover is dismissed
+  const handleCloseAndReturnFocus = useCallback(() => {
+    setOverflowOpen(false)
+
+    if (keyboardInputRef.current && buttonRef.current) {
+      buttonRef.current.focus()
+    }
+  }, [setOverflowOpen])
+
   return (
     <>
       <button
@@ -29,7 +73,9 @@ export const ToolbarOverflow: FC<{
         type="button"
         id="toolbar-more"
         className={className}
-        aria-label="More"
+        aria-label={t('more_editor_toolbar_item')}
+        aria-expanded={overflowOpen}
+        aria-controls="popover-toolbar-overflow"
         onMouseDown={event => {
           event.preventDefault()
           event.stopPropagation()
@@ -49,9 +95,14 @@ export const ToolbarOverflow: FC<{
         // containerPadding={0}
         transition
         rootClose
-        onHide={() => setOverflowOpen(false)}
+        onHide={handleCloseAndReturnFocus}
+        onEntered={handlePopoverFocus}
       >
-        <OLPopover id="popover-toolbar-overflow" ref={overflowRef}>
+        <OLPopover
+          id="popover-toolbar-overflow"
+          ref={overflowRef}
+          role="toolbar"
+        >
           <div className="ol-cm-toolbar-overflow">{children}</div>
         </OLPopover>
       </OLOverlay>

+ 1 - 1
services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown-legacy.tsx

@@ -36,7 +36,7 @@ export const LegacyTableDropdown = memo(() => {
       >
         <button
           type="button"
-          className="ol-cm-toolbar-button btn"
+          className="ol-cm-toolbar-button"
           aria-label={t('toolbar_insert_table')}
           onMouseDown={event => {
             event.preventDefault()

+ 1 - 0
services/web/locales/en.json

@@ -1344,6 +1344,7 @@
   "more_collabs_per_project": "More collaborators per project",
   "more_comments": "More comments",
   "more_compile_time": "More compile time",
+  "more_editor_toolbar_item": "More editor toolbar items",
   "more_info": "More Info",
   "more_options": "More options",
   "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",

+ 8 - 8
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx

@@ -121,7 +121,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Bullet List')
 
     cy.get('.cm-content').should('have.text', ' test')
@@ -134,7 +134,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     cy.get('.cm-content').should('have.text', ' test')
@@ -147,7 +147,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     // expose the markup
@@ -180,7 +180,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     // expose the markup
@@ -205,7 +205,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test\ntest')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     // expose the markup
@@ -242,7 +242,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
 
     cy.get('.cm-line').eq(1).click()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     cy.get('.cm-line').eq(0).type('{upArrow}')
@@ -263,7 +263,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
     mountEditor('test\ntest')
     selectAll()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     // expose the markup
@@ -300,7 +300,7 @@ describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
 
     cy.get('.cm-line').eq(0).click()
 
-    clickToolbarButton('More')
+    clickToolbarButton('More editor toolbar items')
     clickToolbarButton('Numbered List')
 
     // expose the markup