From e47b02b6f2f9ed92c38e3f4d5f11e6432500412a Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 19 Jan 2026 13:17:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20auto-focus=20on?= =?UTF-8?q?=20title=20when=20opening=20a=20doc=20or=20sub-doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improves keyboard navigation and accessibility when content is displayed --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-editor.spec.ts | 2 +- .../src/components/Emoji/EmojiPicker.tsx | 8 +- .../docs/doc-editor/components/DocEditor.tsx | 5 +- .../doc-management/components/DocIcon.tsx | 32 +++++++ .../docs/doc-management/hooks/index.ts | 1 + .../hooks/useDocFocusManagement.tsx | 91 +++++++++++++++++++ .../src/features/docs/doc-management/utils.ts | 12 +++ .../docs/doc-tree/components/DocTree.tsx | 2 +- 9 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocFocusManagement.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b212be3..2811e40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to - ♿(frontend) improve accessibility: - ♿️(frontend) fix subdoc opening and emoji pick focus #1745 + - ♿️(frontend) Keyboard focus Fixes for docs Tree/Editor #1816 ## [4.4.0] - 2026-01-13 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 7933931f..b7fb0f07 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -73,7 +73,7 @@ test.describe('Doc Editor', () => { await page.keyboard.press('Escape'); - await page.locator('.bn-block-outer').last().click(); + await page.locator('.ProseMirror').focus(); await page.keyboard.press('Enter'); diff --git a/src/frontend/apps/impress/src/components/Emoji/EmojiPicker.tsx b/src/frontend/apps/impress/src/components/Emoji/EmojiPicker.tsx index e1177aba..1dc52c7b 100644 --- a/src/frontend/apps/impress/src/components/Emoji/EmojiPicker.tsx +++ b/src/frontend/apps/impress/src/components/Emoji/EmojiPicker.tsx @@ -12,6 +12,7 @@ interface EmojiPickerProps { onClickOutside: () => void; onEmojiSelect: ({ native }: { native: string }) => void; withOverlay?: boolean; + onEscape?: () => void; } export const EmojiPicker = ({ @@ -19,12 +20,17 @@ export const EmojiPicker = ({ onClickOutside, onEmojiSelect, withOverlay = false, + onEscape, }: EmojiPickerProps) => { const { i18n } = useTranslation(); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { - onClickOutside(); + if (onEscape) { + onEscape(); + } else { + onClickOutside(); + } } }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index ad49ebfa..12b51aaf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -5,6 +5,7 @@ import { Box, Loading } from '@/components'; import { DocHeader } from '@/docs/doc-header/'; import { Doc, + useDocFocusManagement, useIsCollaborativeEditable, useProviderStore, } from '@/docs/doc-management'; @@ -82,7 +83,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => { const readOnly = !doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc; const { setIsSkeletonVisible } = useSkeletonStore(); - const isProviderReady = isReady && provider; + const isProviderReady = Boolean(isReady && provider); + + useDocFocusManagement(doc.id, isProviderReady); useEffect(() => { if (isProviderReady) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx index c5518c1d..e794f44f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx @@ -15,6 +15,13 @@ import { } from '@/components'; import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate'; +import { cssSelectors } from '../utils'; + +const getClosestTreeItem = (element: HTMLElement | null) => + element?.closest(cssSelectors.DOC_TREE_ROW) ?? + element?.closest(cssSelectors.DOC_TREE_NODE) ?? + element?.closest('[role="treeitem"]') ?? + null; type DocIconProps = TextType & { buttonProps?: BoxButtonType; @@ -110,6 +117,30 @@ export const DocIcon = ({ setOpenEmojiPicker(false); }; + const handleEscape = () => { + setOpenEmojiPicker(false); + window.requestAnimationFrame(() => { + const localTreeItem = getClosestTreeItem(iconRef.current); + const docTree = document.querySelector( + cssSelectors.DOC_TREE, + ); + const docTreeItem = + localTreeItem || + docTree?.querySelector( + cssSelectors.DOC_TREE_FOCUSED_NODE, + ) || + docTree?.querySelector( + cssSelectors.DOC_TREE_SELECTED_ROW, + ) || + docTree?.querySelector( + cssSelectors.DOC_TREE_SELECTED_NODE, + ) || + document.querySelector(cssSelectors.DOC_TREE_ROOT); + + docTreeItem?.focus(); + }); + }; + return ( <> , document.body, diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index fbcfdc6f..07fb75e7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useCollaboration'; export * from './useCopyDocLink'; export * from './useCreateChildDocTree'; +export * from './useDocFocusManagement'; export * from './useDocTitleUpdate'; export * from './useDocUtils'; export * from './useIsCollaborativeEditable'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocFocusManagement.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocFocusManagement.tsx new file mode 100644 index 00000000..c74b81f8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocFocusManagement.tsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; + +import { cssSelectors } from '@/docs/doc-management/utils'; + +const isWithin = (el: Element | null, selector: string) => + !!el?.closest(selector); + +export const useDocFocusManagement = (docId?: string, isReady = true) => { + // 1) Auto-focus title when opening a doc + useEffect(() => { + if (!docId || !isReady || typeof window === 'undefined') { + return; + } + + const frameId = window.requestAnimationFrame(() => { + const titleElement = document.querySelector( + cssSelectors.DOC_TITLE, + ); + if (!titleElement) { + return; + } + + // Avoid stealing focus if user is already in the doc tree or editor. + const activeEl = document.activeElement; + const active = activeEl instanceof Element ? activeEl : null; + const isInDocUI = + isWithin(active, cssSelectors.DOC_EDITOR_FOCUS) || + isWithin(active, cssSelectors.DOC_TREE); + + const isBodyFocused = activeEl === document.body; + + if (isBodyFocused && !isInDocUI && activeEl !== titleElement) { + titleElement.focus(); + } + }); + + return () => window.cancelAnimationFrame(frameId); + }, [docId, isReady]); + + // 2) Escape from editor/title -> focus back the selected tree item (or root) + useEffect(() => { + if (!docId || !isReady || typeof window === 'undefined') { + return; + } + + const handleFocusShortcut = (event: KeyboardEvent) => { + if (event.key !== 'F6' || event.defaultPrevented) { + return; + } + + const target = event.target instanceof Element ? event.target : null; + const activeEl = document.activeElement; + const active = activeEl instanceof Element ? activeEl : null; + + const isDocFocus = + isWithin(target, cssSelectors.DOC_EDITOR_FOCUS) || + isWithin(active, cssSelectors.DOC_EDITOR_FOCUS) || + isWithin(target, cssSelectors.DOC_TITLE) || + isWithin(active, cssSelectors.DOC_TITLE); + + if (!isDocFocus) { + return; + } + + const docTree = document.querySelector( + cssSelectors.DOC_TREE, + ); + + const docTreeItem = + docTree?.querySelector( + cssSelectors.DOC_TREE_SELECTED_ROW, + ) || + docTree?.querySelector( + cssSelectors.DOC_TREE_SELECTED_NODE, + ) || + document.querySelector(cssSelectors.DOC_TREE_ROOT); + + if (!docTreeItem) { + return; + } + + docTreeItem.focus(); + event.preventDefault(); + event.stopPropagation(); + }; + + document.addEventListener('keydown', handleFocusShortcut, true); + return () => + document.removeEventListener('keydown', handleFocusShortcut, true); + }, [docId, isReady]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index c126cc8a..f2a32ddc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -38,3 +38,15 @@ export const getEmojiAndTitle = (title: string) => { return { emoji: null, titleWithoutEmoji: title }; }; + +export const cssSelectors = { + DOC_TITLE: '.--docs--doc-title-input[contenteditable="true"]', + DOC_TREE_ROOT: '[data-testid="doc-tree-root-item"]', + DOC_TREE: '[data-testid="doc-tree"]', + DOC_EDITOR_FOCUS: '.--docs--main-editor, .--docs--doc-title-input', + DOC_TREE_ROW: '.c__tree-view--row', + DOC_TREE_NODE: '.c__tree-view--node', + DOC_TREE_FOCUSED_NODE: '.c__tree-view--node.isFocused', + DOC_TREE_SELECTED_ROW: '.c__tree-view--row[aria-selected="true"]', + DOC_TREE_SELECTED_NODE: '.c__tree-view--node[aria-selected="true"]', +} as const; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 08fe8005..75b38e3f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -252,7 +252,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { {/* Keyboard instructions for screen readers */} {t( - 'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.', + 'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions. Press F6 to return focus from the document to the tree.', )}