️(frontend) improve accessibility of doc tree

We had different and duplicated accessibility behaviors
between the tree root and the subpages.
This PR unifies the behaviors and improves
accessibility of the doc tree.
We can now:
- directly focus on the tree then navigate
through the tree with the arrows keyboard
- When using F2, we focus on the actions, click
on escape to go back to the tree and continue navigating
- When using F2, and we arrives at the last action,
another F2 will go back to the start of the actions
- When using F2, you can then use the arrows to navigate
through the actions
This commit is contained in:
Anthony LC
2026-09-02 18:02:20 +02:00
parent e91f9dfc91
commit 04446c3466
9 changed files with 650 additions and 466 deletions
@@ -329,98 +329,85 @@ test.describe('Doc Tree', () => {
).toBeHidden();
});
test('keyboard navigation with Enter key opens documents', async ({
test('check the accessibility of the doc tree', async ({
page,
browserName,
}) => {
// Create a parent document
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-nav',
'doc-tree-accessibility',
browserName,
1,
);
await verifyDocName(page, docParent);
// Create a sub-document
const { name: docChild } = await createRootSubPage(
const { name: docChild1 } = await createRootSubPage(
page,
browserName,
'doc-tree-keyboard-child',
'doc-tree-accessibility-child-1',
);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'doc-tree-accessibility-child-2',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
const rootItem = docTree.getByLabel('Root document').first();
const treeRow1 = await getTreeRow(page, docChild1);
const treeRow2 = await getTreeRow(page, docChild2);
// Test keyboard navigation on root document
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus on the root item and press Enter
await rootItem.focus();
await docTree.click();
await page.keyboard.press('Tab');
await expect(rootItem).toBeFocused();
await page.keyboard.press('Enter');
// Verify we navigated to the root document
await verifyDocName(page, docParent);
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('keyboard navigation with F2 focuses root actions button', async ({
page,
browserName,
}) => {
// Create a parent document to initialize the tree
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-f2-root',
browserName,
1,
);
await verifyDocName(page, docParent);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus the root item
await rootItem.focus();
await expect(rootItem).toBeFocused();
// Press F2 → focus should move to the root actions \"Open the document options\" button
await page.keyboard.press('ArrowDown');
await expect(treeRow1).toBeFocused();
await page.keyboard.press('F2');
await expect(
treeRow1.getByRole('button', { name: 'Add emoji' }),
).toBeFocused();
await page.keyboard.press('ArrowRight');
await expect(
treeRow1.getByRole('button', {
name: /Open the document options/i,
}),
).toBeFocused();
await page.keyboard.press('Escape');
await expect(treeRow1).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(rootItem).toBeFocused();
// Check F2
await page.keyboard.press('F2');
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
const rootMoreOptionsButton = rootActions.getByRole('button', {
name: /Open the document options/i,
});
await expect(rootMoreOptionsButton).toBeFocused();
});
test('Shift+Tab from resize handle returns focus to selected sub-doc', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-tree-shift-tab', browserName, 1);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-shift-tab-child',
const rootAddDocButton = rootItem.getByTestId(
'doc-tree-item-actions-add-child',
);
await expect(rootMoreOptionsButton).toBeFocused();
await page.keyboard.press('F2');
await expect(rootAddDocButton).toBeFocused();
await page.keyboard.press('F2');
await expect(rootMoreOptionsButton).toBeFocused();
await page.keyboard.press('ArrowRight');
await expect(rootAddDocButton).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(rootMoreOptionsButton).toBeFocused();
await page.keyboard.press('Enter');
await expect(
page.getByRole('menuitem', { name: /Copy Link/i }),
).toBeVisible();
await page.waitForTimeout(500);
await page.keyboard.press('Escape');
await expect(rootMoreOptionsButton).toBeFocused();
const docTree = page.getByTestId('doc-tree');
const selectedSubDoc = await getTreeRow(page, docChild);
await expect(selectedSubDoc).toHaveAttribute('aria-selected', 'true');
await selectedSubDoc.focus();
await expect(selectedSubDoc).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(treeRow1).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(treeRow2).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Open user menu')).toBeFocused();
@@ -441,6 +428,9 @@ test.describe('Doc Tree', () => {
await page.keyboard.press('Shift+Tab');
await expect(docTree.getByLabel('Root document').first()).toBeFocused();
await page.keyboard.press('Enter');
await verifyDocName(page, docParent);
});
test('it updates the child icon from the tree', async ({
@@ -46,11 +46,13 @@ export const createRootSubPage = async (
}
// Update sub page name
const randomDocs = randomName(docName, browserName, 1);
await updateDocTitle(page, randomDocs[0]);
const [randomDoc] = randomName(docName, browserName, 1);
await updateDocTitle(page, randomDoc);
await expect(docTree.getByText(randomDoc)).toBeVisible();
// Return sub page data
return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson };
return { name: randomDoc, docTreeItem: subPageItem, item: subPageJson };
};
export const clickOnAddRootSubPage = async (page: Page) => {
@@ -1,11 +1,10 @@
import { Command } from 'cmdk';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { PropsWithChildren, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SearchSVG from '@/assets/icons/ui-kit/zoom-rounded.svg';
import { HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useFocusStore } from '@/stores';
import { Box } from '../Box';
@@ -27,11 +26,6 @@ export const QuickSearchInput = ({
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const inputRef = useRef<HTMLInputElement>(null);
const addLastFocus = useFocusStore((state) => state.addLastFocus);
useEffect(() => {
addLastFocus(inputRef.current);
}, [addLastFocus]);
if (children) {
return (
@@ -1,5 +1,4 @@
import {
ButtonElement,
Spinner,
TreeViewItem,
TreeViewNodeProps,
@@ -7,7 +6,7 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-components';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -21,6 +20,7 @@ import {
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useTreeItemActions } from '../hooks/useTreeItemActions';
import { isDocNode } from '../utils';
import SubPageIcon from './../assets/sub-page-logo.svg';
@@ -103,15 +103,43 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
const { node } = props;
const { isLargeScreen, isMobile } = useResponsiveStore();
const { isMobile } = useResponsiveStore();
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const router = useRouter();
const { closePanel } = useLeftPanelStore();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const itemRef = useRef<HTMLAnchorElement>(null);
const focusRow = useCallback(() => {
// Keep react-arborist's notion of the focused node in sync…
node.focus();
/**
* …but move the DOM focus ourselves. The library only does it from an
* effect keyed on `isFocused` *changing*, and it is already true whenever
* focus sits on one of this row's own buttons — so `node.focus()` alone
* would leave focus right where it is.
*/
itemRef.current?.closest<HTMLElement>('.c__tree-view--row')?.focus();
}, [node]);
/**
* F2 / arrows step through the item's actions (emoji button, then the toolbar
* buttons) and Escape leaves them; the very first F2 is handled by the
* ui-components row itself (row → emoji button).
*/
const {
areActionsVisible,
onMenuOpenChange,
handleActionsKeyDown,
itemProps,
} = useTreeItemActions({
isActive: node.isFocused,
focusItem: focusRow,
});
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -146,36 +174,11 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
const isCurrentPage = router.query?.id === doc.id;
const isDeleted = !!doc.deleted_at;
const actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<ButtonElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const isInActions = !!target?.closest('.light-doc-item-actions');
const isOnEmojiButton = !!target?.closest('.--docs--doc-icon');
const shouldOpenActions =
!menuOpen && !isInActions && (node.isFocused || isOnEmojiButton);
if (e.key === 'F2' && shouldOpenActions) {
buttonOptionRef.current?.focus();
e.stopPropagation();
e.preventDefault();
return;
}
};
const handleActionsOpenChange = (isOpen: boolean) => {
setMenuOpen(isOpen);
// When the menu closes (via Escape or activating an option),
// return focus to the tree item so focus is not lost.
if (!isOpen) {
node.focus();
}
};
return (
<StyledLink
{...itemProps}
ref={itemRef}
className="--docs-sub-page-item"
/**
* Conflict with the react-arborist DND.
@@ -191,7 +194,7 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
}
aria-current={isCurrentPage ? 'page' : undefined}
data-testid={`doc-sub-page-item-${doc.id}`}
onKeyDown={handleKeyDown}
onKeyDown={handleActionsKeyDown}
aria-disabled={isDeleted}
onClick={(e) => {
if (isDeleted) {
@@ -220,10 +223,6 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
display: block;
width: 100%;
border-radius: var(--c--globals--spacings--st);
.light-doc-item-actions {
display: ${menuOpen || !isLargeScreen ? 'flex' : 'none'};
right: var(--c--globals--spacings--0);
}
.c__tree-view--node {
padding-right: var(--c--globals--spacings--xxxs);
height: 32px;
@@ -231,12 +230,12 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
.c__tree-view--node.isFocused {
outline: none !important;
border-radius: var(--c--globals--spacings--st);
.light-doc-item-actions {
display: flex;
}
}
/* Remove visual focus from the tree item when focus is on actions or emoji button */
&:has(.light-doc-item-actions *:focus, .--docs--doc-icon:focus-visible)
/* Only one focus ring at a time: the toolbar and emoji draw their own. */
&:has(
.doc-tree-root-item-actions *:focus,
.--docs--doc-icon:focus-visible
)
.c__tree-view--node.isFocused {
box-shadow: none !important;
}
@@ -244,14 +243,6 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
background-color: var(
--c--contextuals--background--semantic--gray--tertiary
);
.light-doc-item-actions {
display: flex;
}
}
&:focus-within {
.light-doc-item-actions {
display: flex;
}
}
.row.preview & {
background-color: inherit;
@@ -294,23 +285,13 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
{displayTitle}
</Text>
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions actions"
role="toolbar"
aria-label={t('Actions for {{title}}', { title: displayTitle })}
>
{areActionsVisible && (
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={handleActionsOpenChange}
parentId={node.data.parentKey}
onOpenChange={onMenuOpenChange}
onCreateSuccess={afterCreate}
actionsRef={actionsRef}
buttonOptionRef={buttonOptionRef}
/>
</Box>
)}
</TreeViewItem>
</StyledLink>
);
@@ -1,66 +1,33 @@
import { OpenMap, useTreeContext } from '@gouvfr-lasuite/ui-components';
import {
ButtonElement,
OpenMap,
TreeDataItem,
TreeView,
TreeViewMoveResult,
useResponsive,
useTreeContext,
} from '@gouvfr-lasuite/ui-components';
import { useRouter } from 'next/router';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NodeApi } from 'react-arborist';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Overlayer, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
SimpleDocItem,
useMoveDoc,
useTrans,
} from '@/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel/stores/useLeftPanelStore';
import { Box } from '@/components';
import { Doc } from '@/docs/doc-management';
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
import { useResponsiveStore } from '@/stores/useResponsiveStore';
import { CLASS_DOC_TITLE } from '../../doc-header';
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
import { findIndexInTree, isDocNode } from '../utils';
import { findIndexInTree } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
import { DocTreeItemActions } from './DocTreeItemActions';
import { DocTreeRoot } from './DocTreeRoot';
import { DocTreeSubpages } from './DocTreeSubpages';
type DocTreeProps = {
currentDoc: Doc;
};
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { closePanel } = useLeftPanelStore();
const { untitledDocument } = useTrans();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const rootIsSelected =
!!treeContext?.root?.id &&
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const rootItemRef = useRef<HTMLDivElement>(null);
const rootActionsRef = useRef<HTMLDivElement>(null);
const rootButtonOptionRef = useRef<ButtonElement | null>(null);
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
@@ -81,80 +48,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
setInitialOpenState(undefined);
}, [treeContext]);
const selectRoot = useCallback(() => {
if (treeContext?.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
}
}, [treeContext]);
const navigateToRoot = useCallback(() => {
const id = treeContext?.root?.id;
if (id) {
void router.push(`/docs/${id}`);
}
}, [router, treeContext?.root?.id]);
const handleRootFocus = useCallback(() => {
selectRoot();
}, [selectRoot]);
// Handle keyboard navigation for root item
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const isInActions = !!target?.closest('.doc-tree-root-item-actions');
const isOnEmojiButton = !!target?.closest('.--docs--doc-icon');
const isOnRootItem = target === e.currentTarget;
if (e.key === 'F2' && !rootActionsOpen && !isInActions) {
if (
isOnEmojiButton ||
isOnRootItem ||
target?.classList.contains('c__tree-view--node')
) {
e.preventDefault();
rootButtonOptionRef.current?.focus();
}
return;
}
if (isInActions) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (currentDoc.id === treeContext?.root?.id) {
document.querySelector<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
} else {
selectRoot();
navigateToRoot();
}
}
},
[
selectRoot,
navigateToRoot,
rootActionsOpen,
currentDoc.id,
treeContext?.root?.id,
],
);
// Handle menu open/close for root item - mirrors DocSubPageItem behavior
const handleRootActionsOpenChange = useCallback((isOpen: boolean) => {
setRootActionsOpen(isOpen);
// When the menu closes, return focus to the root tree item
// (same behavior as DocSubPageItem for consistency)
// Use requestAnimationFrame for smoother focus transition without flickering
if (!isOpen) {
requestAnimationFrame(() => {
rootItemRef.current?.focus();
});
}
}, []);
/**
* This effect is used to reset the tree when a new document
* that is not part of the current tree is loaded.
@@ -314,7 +207,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 the up and down arrow keys to move between documents, and Enter to open one. Press F2 to reach the actions of a document and to move between them, use Escape to go back to the document list.',
)}
</Box>
<Box
@@ -323,229 +216,24 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
z-index: 2;
`}
>
<Box
ref={rootItemRef}
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || untitledDocument })}`}
aria-selected={rootIsSelected}
tabIndex={0}
onFocus={handleRootFocus}
onKeyDown={handleRootKeyDown}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: var(--c--globals--spacings--st);
width: 100%;
min-width: 200px;
background-color: ${
rootIsSelected || rootActionsOpen
? 'var(--c--contextuals--background--semantic--contextual--primary)'
: 'transparent'
};
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
border-radius: var(--c--globals--spacings--st);
}
.doc-tree-root-item-actions {
opacity: ${rootActionsOpen ? '1' : '0'};
display: ${rootActionsOpen ? 'flex' : 'none'};
&:has(.isOpen) {
opacity: 1;
}
}
&:hover,
&:focus-visible,
&:focus-within {
.doc-tree-root-item-actions {
display: flex;
opacity: 1;
}
}
/* Remove visual focus from the root item when focus is on the actions */
&:has(.doc-tree-root-item-actions *:focus) {
box-shadow: none !important;
}
`}
>
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
void router.push(`/docs/${treeContext?.root?.id}`);
}}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || untitledDocument}`}
tabIndex={-1} // avoid double tabstop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showDate={true} />
<DocTreeItemActions
doc={treeContext.root}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
if (isMobile) {
closePanel();
}
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={handleRootActionsOpenChange}
actionsRef={rootActionsRef}
buttonOptionRef={rootButtonOptionRef}
/>
</Box>
</StyledLink>
</Box>
<DocTreeRoot
currentDoc={currentDoc}
rootItemRef={rootItemRef}
treeContext={treeContext}
/>
</Box>
{initialOpenState &&
treeContext.treeData.nodes.length > 0 &&
treeRoot && (
<DocTreeView
<DocTreeSubpages
doc={currentDoc}
treeRoot={treeRoot}
initialOpenState={initialOpenState}
rootNodeId={treeContext.root.id}
rootItem={rootItemRef.current}
rootItemRef={rootItemRef}
/>
)}
</Box>
);
};
interface DocTreeViewProps {
doc: Doc;
treeRoot: HTMLElement;
initialOpenState: OpenMap;
rootNodeId: string;
rootItem: HTMLDivElement | null;
}
const DocTreeView = memo(function DocTreeView({
doc,
treeRoot,
initialOpenState,
rootNodeId,
rootItem,
}: DocTreeViewProps) {
const { isDesktop } = useResponsive();
const treeContext = useTreeContext<Doc | null>();
const { mutate: moveDoc } = useMoveDoc();
const { query } = useRouter();
const handleMove = useCallback(
(result: TreeViewMoveResult) => {
moveDoc({
sourceDocumentId: result.sourceId,
targetDocumentId: result.targetModeId,
position: result.mode,
});
treeContext?.treeData.handleMove(result);
},
[moveDoc, treeContext],
);
const canDrop = useCallback(
({ parentNode }: { parentNode: NodeApi<TreeDataItem<Doc>> | null }) => {
const parentValue = parentNode?.data.value;
if (!parentValue || !isDocNode(parentValue)) {
return doc.abilities.move && isDesktop;
}
return parentValue.abilities.move && isDesktop;
},
[doc.abilities.move, isDesktop],
);
const canDrag = useCallback(
(node: TreeDataItem<Doc>) => {
if (!isDocNode(node.value)) {
return false;
}
return node.value.abilities.move && isDesktop;
},
[isDesktop],
);
const handleRowKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
rootItem?.focus();
return;
}
if (e.key !== 'Enter') {
return;
}
const target = e.target as HTMLElement | null;
if (
!target ||
!(
target.classList.contains('c__tree-view--row') ||
target.classList.contains('c__tree-view--node')
)
) {
return;
}
const treeItem = e.currentTarget.querySelector('[role="treeitem"]');
if (treeItem?.getAttribute('aria-selected') === 'true') {
e.preventDefault();
document.querySelector<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
return;
}
e.currentTarget
.querySelector<HTMLDivElement>('.c__tree-view--node')
?.click();
},
[rootItem],
);
return (
<Overlayer isOverlay={doc.deleted_at != null} inert>
<TreeView
dndRootElement={treeRoot}
initialOpenState={initialOpenState}
afterMove={handleMove}
selectedNodeId={
(query.id as string | undefined) ??
treeContext?.initialTargetId ??
undefined
}
canDrop={canDrop}
canDrag={canDrag}
rootNodeId={rootNodeId}
renderNode={DocSubPageItem}
rowProps={{
onKeyDown: handleRowKeyDown,
}}
/>
</Overlayer>
);
});
@@ -0,0 +1,208 @@
import { TreeContextType } from '@gouvfr-lasuite/ui-components';
import { useRouter } from 'next/router';
import { RefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
import { Doc, SimpleDocItem, useTrans } from '@/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel/stores/useLeftPanelStore';
import { useResponsiveStore } from '@/stores/useResponsiveStore';
import { CLASS_DOC_TITLE } from '../../doc-header';
import { useTreeItemActions } from '../hooks/useTreeItemActions';
import { isWithinTreeItemActions } from '../utils';
import { DocTreeItemActions } from './DocTreeItemActions';
type DocTreeRootProps = {
currentDoc: Doc;
rootItemRef: RefObject<HTMLDivElement | null>;
treeContext: TreeContextType<Doc | null>;
};
export const DocTreeRoot = ({
currentDoc,
rootItemRef,
treeContext,
}: DocTreeRootProps) => {
const { isMobile } = useResponsiveStore();
const { closePanel } = useLeftPanelStore();
const { untitledDocument } = useTrans();
const { t } = useTranslation();
const router = useRouter();
const root = treeContext.root;
const treeApiRef = treeContext.treeApiRef;
const isSelected =
!!root?.id && treeContext.treeData.selectedNode?.id === root.id;
const selectRoot = useCallback(() => {
if (root) {
treeContext.treeData.setSelectedNode(root);
}
}, [treeContext.treeData, root]);
const focusRootItem = useCallback(() => {
rootItemRef.current?.focus();
}, [rootItemRef]);
const {
areActionsVisible,
isMenuOpen,
onMenuOpenChange,
handleActionsKeyDown,
itemProps,
} = useTreeItemActions({
focusItem: focusRootItem,
});
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
// F2 / ArrowLeft / ArrowRight / Escape rove within the actions group.
if (handleActionsKeyDown(event)) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
// ArrowDown enters the sub pages at the first row — from the item
// itself or from one of its actions, the same as the sub page rows.
// ArrowUp has nowhere to go (the root is the top) but must not scroll
// the panel.
const api = treeApiRef.current;
const firstNode = api?.firstNode;
if (event.key === 'ArrowDown' && api && firstNode) {
api.focus(firstNode);
// react-arborist only moves DOM focus from the effect that fires
// when a row's `isFocused` flips to true. When it already considers
// the first row focused that effect never runs, so move the focus
// ourselves, the way `focusRow` does in `DocSubPageItem`.
rootItemRef.current
?.closest('[data-testid="doc-tree"]')
?.querySelector<HTMLElement>(
`[data-testid="doc-sub-page-item-${firstNode.id}"]`,
)
?.closest<HTMLElement>('.c__tree-view--row')
?.focus();
}
return;
}
// The remaining keys act on the item itself, never on its focused actions.
if (isWithinTreeItemActions(event)) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
// Already on this document: move on to its title rather than reloading.
if (currentDoc.id === root?.id) {
document.querySelector<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
} else if (root) {
selectRoot();
void router.push(`/docs/${root.id}`);
}
}
},
[
handleActionsKeyDown,
selectRoot,
router,
currentDoc.id,
root,
treeApiRef,
rootItemRef,
],
);
if (!root) {
return null;
}
const title = root.title || untitledDocument;
return (
<Box
{...itemProps}
ref={rootItemRef}
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={t('Root document {{title}}', { title })}
aria-selected={isSelected}
tabIndex={0}
onKeyDown={handleKeyDown}
$css={css`
padding: var(--c--globals--spacings--2xs);
border-radius: var(--c--globals--spacings--st);
width: 100%;
min-width: 200px;
background-color: ${
isSelected || isMenuOpen
? 'var(--c--contextuals--background--semantic--contextual--primary)'
: 'transparent'
};
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
border-radius: var(--c--globals--spacings--st);
}
/* Only one focus ring at a time: the toolbar draws its own. */
&:has(.doc-tree-root-item-actions *:focus) {
box-shadow: none !important;
}
`}
>
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${root.id}`}
onClick={(e) => {
if (!e.currentTarget.contains(e.target as Node)) {
e.preventDefault();
return;
}
e.stopPropagation();
e.preventDefault();
selectRoot();
void router.push(`/docs/${root.id}`);
}}
aria-label={`${t('Open root document')}: ${title}`}
tabIndex={-1} // the item itself is the tab stop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={root} showDate={true} />
{areActionsVisible && (
<DocTreeItemActions
doc={root}
onOpenChange={onMenuOpenChange}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: root.id,
};
treeContext.treeData.addChild(null, newDoc);
if (isMobile) {
closePanel();
}
}}
/>
)}
</Box>
</StyledLink>
</Box>
);
};
@@ -0,0 +1,164 @@
import {
OpenMap,
TreeDataItem,
TreeView,
TreeViewMoveResult,
useResponsive,
useTreeContext,
} from '@gouvfr-lasuite/ui-components';
import { useRouter } from 'next/router';
import { RefObject, memo, useCallback, useEffect } from 'react';
import { NodeApi } from 'react-arborist';
import { Overlayer } from '@/components';
import { CLASS_DOC_TITLE } from '@/docs/doc-header';
import { Doc, useMoveDoc } from '@/docs/doc-management';
import { isDocNode, isWithinTreeItemActions } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
interface DocTreeSubPagesProps {
doc: Doc;
treeRoot: HTMLElement;
initialOpenState: OpenMap;
rootNodeId: string;
rootItemRef: RefObject<HTMLDivElement | null>;
}
export const DocTreeSubpages = memo(function DocTreeSubpages({
doc,
treeRoot,
initialOpenState,
rootNodeId,
rootItemRef,
}: DocTreeSubPagesProps) {
const { isDesktop } = useResponsive();
const treeContext = useTreeContext<Doc | null>();
const { mutateAsync: moveDoc } = useMoveDoc();
const { query } = useRouter();
/**
* The root item is the tree's only Tab stop; the sub pages are reached from
* it with the arrow keys. react-arborist hardcodes `tabIndex=0` on its
* container and `ui-components` does not forward `renderContainer`, so the
* attribute is corrected here. React leaves it alone afterwards: it only
* writes an attribute when the rendered prop value changes, and this one
* stays `0` for the lifetime of the container.
*/
useEffect(() => {
treeRoot
.querySelector<HTMLElement>('.c__tree-view--container [role="tree"]')
?.setAttribute('tabindex', '-1');
}, [treeRoot]);
const handleMove = useCallback(
async (result: TreeViewMoveResult) => {
await moveDoc({
sourceDocumentId: result.sourceId,
targetDocumentId: result.targetModeId,
position: result.mode,
});
treeContext?.treeData.handleMove(result);
},
[moveDoc, treeContext?.treeData],
);
const canDrop = useCallback(
({ parentNode }: { parentNode: NodeApi<TreeDataItem<Doc>> | null }) => {
const parentValue = parentNode?.data.value;
if (!parentValue || !isDocNode(parentValue)) {
return doc.abilities.move && isDesktop;
}
return parentValue.abilities.move && isDesktop;
},
[doc.abilities.move, isDesktop],
);
const canDrag = useCallback(
(node: TreeDataItem<Doc>) => {
if (!isDocNode(node.value)) {
return false;
}
return node.value.abilities.move && isDesktop;
},
[isDesktop],
);
const handleRowKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Tab') {
e.stopPropagation();
if (e.shiftKey) {
e.preventDefault();
rootItemRef.current?.focus();
}
return;
}
// ArrowUp on the first row leaves the sub pages for the root item;
// deeper rows fall through to the tree's own row-to-row navigation.
if (e.key === 'ArrowUp' && !isWithinTreeItemActions(e)) {
const api = treeContext?.treeApiRef.current;
if (api && !api.prevNode) {
e.preventDefault();
e.stopPropagation();
rootItemRef.current?.focus();
}
return;
}
if (e.key !== 'Enter') {
return;
}
// Classes rendered by the `ui-components` TreeView / TreeViewItem.
const target = e.target as HTMLElement | null;
if (
!target ||
!(
target.classList.contains('c__tree-view--row') ||
target.classList.contains('c__tree-view--node')
)
) {
return;
}
// Already on this document: move on to its title rather than reloading.
const treeItem = e.currentTarget.querySelector('[role="treeitem"]');
if (treeItem?.getAttribute('aria-selected') === 'true') {
e.preventDefault();
document.querySelector<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
return;
}
e.currentTarget
.querySelector<HTMLDivElement>('.c__tree-view--node')
?.click();
},
[rootItemRef, treeContext],
);
return (
<Overlayer isOverlay={doc.deleted_at != null} inert>
<TreeView
dndRootElement={treeRoot}
initialOpenState={initialOpenState}
afterMove={handleMove}
selectedNodeId={
(query.id as string | undefined) ??
treeContext?.initialTargetId ??
undefined
}
canDrop={canDrop}
canDrag={canDrag}
rootNodeId={rootNodeId}
renderNode={DocSubPageItem}
rowProps={{
onKeyDown: handleRowKeyDown,
}}
/>
</Overlayer>
);
});
@@ -0,0 +1,148 @@
import {
FocusEvent,
HTMLAttributes,
KeyboardEvent,
useCallback,
useMemo,
useState,
} from 'react';
import { useResponsiveStore } from '@/stores';
import { isWithinTreeItemActions } from '../utils';
type UseTreeItemActionsProps = {
isActive?: boolean;
onFocus?: () => void;
focusItem: () => void;
};
type UseTreeItemActionsReturn = {
/** Whether `DocTreeItemActions` should be rendered for this item. */
areActionsVisible: boolean;
isMenuOpen: boolean;
onMenuOpenChange: (isOpen: boolean) => void;
/**
* Keyboard access to the actions, to run first in the item's `onKeyDown`.
* They form a single roving group — the emoji button then the toolbar
* buttons, in DOM order:
* - F2 enters the group / steps to the next button, wrapping;
* - ArrowLeft / ArrowRight move within it once a button is focused;
* - Escape leaves it for the item.
*
* Returns whether the event was handled, so the caller can stop there.
*/
handleActionsKeyDown: (event: KeyboardEvent<HTMLElement>) => boolean;
/** Spread on the element wrapping the item and its actions. */
itemProps: Required<
Pick<
HTMLAttributes<HTMLElement>,
'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur'
>
>;
};
/** Focusable action buttons inside `container`, in DOM order. */
const getActionButtons = (container: HTMLElement) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('button')).filter(
(button) =>
!button.disabled && button.getAttribute('aria-disabled') !== 'true',
);
/**
* Drives how a tree item reveals its actions, across every input method:
* pointer (hover), keyboard (focus, then F2 / arrows to step through them) and
* touch (always visible, since there is no hover).
*
* Visibility is React state rather than `:hover` / `:focus-within` CSS, so the
* actions — and the fairly heavy dropdown menu they mount — only exist for the
* one item the user is interacting with instead of for every row in the tree.
*/
export const useTreeItemActions = ({
isActive = false,
onFocus,
focusItem,
}: UseTreeItemActionsProps): UseTreeItemActionsReturn => {
const { isMobile } = useResponsiveStore();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isPointerOver, setIsPointerOver] = useState(false);
const [hasFocusWithin, setHasFocusWithin] = useState(false);
const onMenuOpenChange = useCallback((isOpen: boolean) => {
setIsMenuOpen(isOpen);
}, []);
const handleActionsKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
// While the menu is open the keyboard belongs to it: Escape closes it and
// `onMenuOpenChange` restores focus from there.
if (isMenuOpen) {
return false;
}
const buttons = getActionButtons(event.currentTarget);
const current = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
// F2 enters the group (or steps forward once inside).
if (event.key === 'F2' && buttons.length > 0) {
event.preventDefault();
// Keep the TreeView row from re-focusing the emoji button behind us.
event.stopPropagation();
buttons[(current + 1) % buttons.length].focus();
return true;
}
// Arrows only rove once focus is already on one of the buttons, so that
// an arrow on the bare tree item still reaches the tree's own handler.
if (
(event.key === 'ArrowRight' || event.key === 'ArrowLeft') &&
current !== -1
) {
event.preventDefault();
event.stopPropagation();
const delta = event.key === 'ArrowRight' ? 1 : -1;
buttons[(current + delta + buttons.length) % buttons.length].focus();
return true;
}
if (event.key === 'Escape' && isWithinTreeItemActions(event)) {
event.preventDefault();
event.stopPropagation();
focusItem();
return true;
}
return false;
},
[isMenuOpen, focusItem],
);
const itemProps = useMemo(
() => ({
onMouseEnter: () => setIsPointerOver(true),
onMouseLeave: () => setIsPointerOver(false),
onFocus: () => {
setHasFocusWithin(true);
onFocus?.();
},
onBlur: (event: FocusEvent<HTMLElement>) => {
// Focus moving between the item and its own actions is not a blur.
if (!event.currentTarget.contains(event.relatedTarget)) {
setHasFocusWithin(false);
}
},
}),
[onFocus],
);
return {
areActionsVisible:
isMobile || isMenuOpen || isPointerOver || hasFocusWithin || isActive,
isMenuOpen,
onMenuOpenChange,
handleActionsKeyDown,
itemProps,
};
};
@@ -9,6 +9,13 @@ import { useContext } from 'react';
import { Doc } from '../doc-management';
export const CLASS_TREE_ITEM_ACTIONS = 'doc-tree-root-item-actions';
export const isWithinTreeItemActions = (event: React.SyntheticEvent) =>
!!(event.target as HTMLElement | null)?.closest(
`.${CLASS_TREE_ITEM_ACTIONS}`,
);
/**
* Type guard to check if a tree node value is a Doc (as opposed to a
* ui-kit synthetic node like VIEW_MORE, SEPARATOR, TITLE, or SIMPLE_NODE).
@@ -54,6 +61,8 @@ export const syncDocInTree = (
} else if (treeContext.treeData.getNode(docId)) {
treeContext.treeData.updateNode(docId, data);
}
treeContext.treeApiRef.current?.focus(docId);
};
export const findIndexInTree = (