️(frontend) auto-focus on title when opening a doc or sub-doc

improves keyboard navigation and accessibility when content is displayed
This commit is contained in:
Cyril 2026-01-19 13:17:08 +01:00
parent 75f71368f4
commit e47b02b6f2
No known key found for this signature in database
GPG key ID: D5E8474B0AB0064A
9 changed files with 150 additions and 4 deletions

View file

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

View file

@ -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');

View file

@ -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<HTMLDivElement>) => {
if (event.key === 'Escape') {
onClickOutside();
if (onEscape) {
onEscape();
} else {
onClickOutside();
}
}
};

View file

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

View file

@ -15,6 +15,13 @@ import {
} from '@/components';
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
import { cssSelectors } from '../utils';
const getClosestTreeItem = (element: HTMLElement | null) =>
element?.closest<HTMLElement>(cssSelectors.DOC_TREE_ROW) ??
element?.closest<HTMLElement>(cssSelectors.DOC_TREE_NODE) ??
element?.closest<HTMLElement>('[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<HTMLElement>(
cssSelectors.DOC_TREE,
);
const docTreeItem =
localTreeItem ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_FOCUSED_NODE,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_ROW,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_NODE,
) ||
document.querySelector<HTMLElement>(cssSelectors.DOC_TREE_ROOT);
docTreeItem?.focus();
});
};
return (
<>
<BoxButton
@ -151,6 +182,7 @@ export const DocIcon = ({
onEmojiSelect={handleEmojiSelect}
onClickOutside={handleClickOutside}
withOverlay={true}
onEscape={handleEscape}
/>
</Box>,
document.body,

View file

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

View file

@ -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<HTMLElement>(
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<HTMLElement>(
cssSelectors.DOC_TREE,
);
const docTreeItem =
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_ROW,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_NODE,
) ||
document.querySelector<HTMLElement>(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]);
};

View file

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

View file

@ -252,7 +252,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
{/* Keyboard instructions for screen readers */}
<Box id="doc-tree-keyboard-instructions" className="sr-only">
{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.',
)}
</Box>
<Box