From 2731ffaf101f44afa2ed02cb4bdf3a5ad142c523 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:04:30 +0200 Subject: [PATCH] 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 --- .../web/frontend/extracted-translations.json | 1 + .../components/file-tree-context-menu.tsx | 49 ++++++++++++--- .../components/toolbar/button-menu.tsx | 2 +- .../components/toolbar/overflow.tsx | 59 +++++++++++++++++-- .../table-inserter-dropdown-legacy.tsx | 2 +- services/web/locales/en.json | 1 + .../codemirror-editor-visual-toolbar.spec.tsx | 16 ++--- 7 files changed, 107 insertions(+), 23 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a336213296..99bd9ed840 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx index 957ed0cbdb..18b8ee117d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx +++ b/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(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) { - if (event.key === 'Tab') { + function handleClose(event: React.KeyboardEvent) { + 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(
{ event.preventDefault() diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx index 574780f761..9d73707db2 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx +++ b/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 }> = ({ overflowed, overflowOpen, setOverflowOpen, overflowRef, children }) => { + const { t } = useTranslation() + const buttonRef = useRef(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 ( <>