Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to
### Fixed

- 🐛(nginx) fix / location to handle new static pages
- ♿(frontend) improve accessibility:
- ♿️Improve keyboard accessibility for the document tree #1681

## [4.0.0] - 2025-12-01

Expand Down
34 changes: 34 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,40 @@ test.describe('Doc Tree', () => {
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 \"More options\" button
await page.keyboard.press('F2');

const rootActions = rootItem.locator('.doc-tree-root-item-actions');
const rootMoreOptionsButton = rootActions.getByRole('button', {
name: /more options/i,
});

await expect(rootMoreOptionsButton).toBeFocused();
});

test('it updates the child icon from the tree', async ({
page,
browserName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

Expand Down Expand Up @@ -92,20 +92,43 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};

const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
const isDisabled = !!doc.deleted_at;
const actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<HTMLDivElement | null>(null);

// Active le document avec Enter/Espace via un handler global sur la tree-view
useKeyboardActivation(
['Enter'],
['Enter', ' '],
isActive && !menuOpen,
handleActivate,
true,
'.c__tree-view',
);

const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
const isDisabled = !!doc.deleted_at;
const handleKeyDown = (e: React.KeyboardEvent) => {
// F2: focus first action button
const shouldOpenActions = !menuOpen && node.isFocused;
if (e.key === 'F2' && shouldOpenActions) {
buttonOptionRef.current?.focus();
e.stopPropagation();
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 (
<Box
Expand All @@ -117,6 +140,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
$css={css`
background-color: var(--c--globals--colors--gray-000);
.light-doc-item-actions {
Expand All @@ -127,6 +151,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !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)
.c__tree-view--node.isFocused {
box-shadow: none !important;
}
&:hover {
background-color: var(
Expand All @@ -137,6 +169,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
display: flex;
}
}
&:focus-within {
.light-doc-item-actions {
display: flex;
}
}
.row.preview & {
background-color: inherit;
}
Expand All @@ -152,7 +189,36 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
$size="sm"
docId={doc.id}
title={doc.title}
buttonProps={{
$css: css`
&:focus-visible {
outline: 2px solid var(--c--globals--colors--brand-500);
outline-offset: var(--c--globals--spacings--4xs);
}
`,
}}
/>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
$css={css`
margin-left: auto;
order: 2;
`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={handleActionsOpenChange}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
actionsRef={actionsRef}
buttonOptionRef={buttonOptionRef}
/>
</Box>
<BoxButton
onClick={(e) => {
e.stopPropagation();
Expand Down Expand Up @@ -196,21 +262,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
)}
</Box>
</BoxButton>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</TreeViewItem>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

Expand Down Expand Up @@ -35,6 +35,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
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<HTMLDivElement | null>(null);

const { t } = useTranslation();

Expand Down Expand Up @@ -88,18 +91,45 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
selectRoot();
}, [selectRoot]);

// activate root document with enter or space
// Handle keyboard navigation for root item
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// F2: focus first action button
if (e.key === 'F2' && !rootActionsOpen) {
e.preventDefault();
rootButtonOptionRef.current?.focus();
return;
}

// Ignore if focus is in actions
const target = e.target as HTMLElement | null;
if (target?.closest('.doc-tree-root-item-actions')) {
return;
}

if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRoot();
navigateToRoot();
}
},
[selectRoot, navigateToRoot],
[selectRoot, navigateToRoot, rootActionsOpen],
);

// 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.
Expand Down Expand Up @@ -180,6 +210,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
$height="100%"
role="tree"
aria-label={t('Document tree')}
aria-describedby="doc-tree-keyboard-instructions"
$css={css`
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
Expand All @@ -199,13 +230,20 @@ 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.',
)}
</Box>
<Box
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
$css={css`
z-index: 2;
`}
>
<Box
ref={rootItemRef}
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
Expand Down Expand Up @@ -234,20 +272,25 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}

.doc-tree-root-item-actions {
display: ${rootActionsOpen ? 'flex' : 'none'};
display: flex;
opacity: ${rootActionsOpen ? '1' : '0'};

&:has(.isOpen) {
opacity: 1;
}
}
&:hover,
&:focus-visible {
&: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
Expand Down Expand Up @@ -281,7 +324,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
onOpenChange={handleRootActionsOpenChange}
actionsRef={rootActionsRef}
buttonOptionRef={rootButtonOptionRef}
/>
</Box>
</StyledLink>
Expand Down
Loading
Loading