mirror of
https://github.com/suitenumerique/docs
synced 2026-04-21 13:37:20 +00:00
♿️(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:
parent
75f71368f4
commit
e47b02b6f2
9 changed files with 150 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue