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
This commit is contained in:
Rebeka Dekany
2025-04-29 16:04:30 +02:00
committed by Copybot
parent 14c82ac94d
commit 2731ffaf10
7 changed files with 107 additions and 23 deletions

View File

@@ -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": "",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.",

View File

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