diff --git a/CHANGELOG.md b/CHANGELOG.md index 88891456..904a19e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to - 🐛(backend) create a link_trace record for on-boarding documents - 🐛(backend) manage race condition when creating sandbox document +- ♿️(frontend) improve doc tree keyboard navigation #1981 ## [v4.7.0] - 2026-03-09 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index f5e90c81..810eced6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -271,6 +271,40 @@ test.describe('Doc Tree', () => { await expect(rootMoreOptionsButton).toBeFocused(); }); + test('Shift+Tab from resize handle returns focus to selected sub-doc', async ({ + page, + browserName, + }) => { + const [docParent] = await createDoc( + page, + 'doc-tree-shift-tab', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const { name: docChild } = await createRootSubPage( + page, + browserName, + 'doc-tree-shift-tab-child', + ); + + const selectedSubDoc = await getTreeRow(page, docChild); + await expect(selectedSubDoc).toHaveAttribute('aria-selected', 'true'); + + const resizeHandle = page.locator('[data-panel-resize-handle-id]').first(); + await expect(resizeHandle).toBeVisible(); + + await selectedSubDoc.focus(); + await expect(selectedSubDoc).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(resizeHandle).toBeFocused(); + + await page.keyboard.press('Shift+Tab'); + await expect(selectedSubDoc).toBeFocused(); + }); + test('it updates the child icon from the tree', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index bc926d81..0ff9c6d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -159,7 +159,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { return ( ) => { docId={doc.id} title={doc.title} buttonProps={{ + tabIndex: -1, $css: css` &:focus-visible { outline: 2px solid var(--c--globals--colors--brand-500); @@ -220,6 +221,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { e.stopPropagation(); handleActivate(); }} + tabIndex={-1} $width="100%" $direction="row" $gap={spacingsTokens['xs']} 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 a28bffbf..1ef7fbc2 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 @@ -19,6 +19,7 @@ import { useTrans, } from '@/docs/doc-management'; +import { CLASS_DOC_TITLE } from '../../doc-header'; import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree'; import { findIndexInTree } from '../utils'; @@ -120,11 +121,21 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - selectRoot(); - navigateToRoot(); + if (currentDoc.id === treeContext?.root?.id) { + document.querySelector(`.${CLASS_DOC_TITLE}`)?.focus(); + } else { + selectRoot(); + navigateToRoot(); + } } }, - [selectRoot, navigateToRoot, rootActionsOpen], + [ + selectRoot, + navigateToRoot, + rootActionsOpen, + currentDoc.id, + treeContext?.root?.id, + ], ); // Handle menu open/close for root item - mirrors DocSubPageItem behavior @@ -142,6 +153,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { }, []); const handleRowKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + rootItemRef.current?.focus(); + return; + } + if (e.key !== 'Enter') { return; } @@ -157,6 +175,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { return; } + const treeItem = e.currentTarget.querySelector('[role="treeitem"]'); + if (treeItem?.getAttribute('aria-selected') === 'true') { + e.preventDefault(); + document.querySelector(`.${CLASS_DOC_TITLE}`)?.focus(); + return; + } + e.currentTarget .querySelector('.c__tree-view--node') ?.click();