(frontend) keyboard support in sub-documents with f2 options access

adds f2 shortcut to open options menu in sub-documents

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) adds f2 shortcut using a fakenode since it's outside the treeview

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add sr-only instructions with aria-describedby links

improves screen reader support with contextual accessibility guidance

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add e2e test to check focus behavior with F2 shortcut

ensures F2 correctly focuses the expected UI element

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-12-02 12:13:07 +01:00
parent d47b5e6a90
commit 48aa4971ec
6 changed files with 194 additions and 68 deletions

View File

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

View File

@@ -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';
@@ -20,8 +20,6 @@ import {
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -46,7 +44,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -92,20 +89,34 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['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 actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
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
@@ -117,6 +128,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 {
@@ -127,6 +139,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(
@@ -137,6 +157,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
display: flex;
}
}
&:focus-within {
.light-doc-item-actions {
display: flex;
}
}
.row.preview & {
background-color: inherit;
}
@@ -152,7 +177,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();
@@ -196,21 +250,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>
);

View File

@@ -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';
@@ -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();
@@ -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.
@@ -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 {
@@ -199,6 +230,12 @@ 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`
@@ -206,6 +243,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
`}
>
<Box
ref={rootItemRef}
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
@@ -234,7 +272,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
.doc-tree-root-item-actions {
display: ${rootActionsOpen ? 'flex' : 'none'};
display: flex;
opacity: ${rootActionsOpen ? '1' : '0'};
&:has(.isOpen) {
@@ -242,12 +280,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}
&: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
@@ -281,7 +324,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
onOpenChange={handleRootActionsOpenChange}
actionsRef={rootActionsRef}
buttonOptionRef={rootButtonOptionRef}
/>
</Box>
</StyledLink>

View File

@@ -1,10 +1,12 @@
import {
DropdownMenu,
DropdownMenuOption,
useArrowRoving,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -30,6 +32,8 @@ type DocTreeItemActionsProps = {
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
};
export const DocTreeItemActions = ({
@@ -39,7 +43,13 @@ export const DocTreeItemActions = ({
onCreateSuccess,
onOpenChange,
parentId,
actionsRef,
buttonOptionRef,
}: DocTreeItemActionsProps) => {
const internalActionsRef = useRef<HTMLDivElement | null>(null);
const targetActionsRef = actionsRef ?? internalActionsRef;
const internalButtonRef = useRef<HTMLDivElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();
const deleteModal = useModal();
@@ -47,6 +57,9 @@ export const DocTreeItemActions = ({
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc | null>();
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
useArrowRoving(targetActionsRef.current);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (duplicatedDoc) => {
// Reset the tree context root will reset the full tree view.
@@ -160,30 +173,45 @@ export const DocTreeItemActions = ({
};
return (
<Box className="doc-tree-root-item-actions">
<Box className="doc-tree-root-item-actions actions">
<Box
ref={targetActionsRef}
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
tabIndex={-1}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<Icon
<BoxButton
ref={targetButtonRef}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenChange?.(!isOpen);
}}
iconName="more_horiz"
variant="filled"
aria-label={t('More options')}
tabIndex={-1}
$theme="brand"
$variation="secondary"
aria-label={t('More options')}
/>
$css={css`
&:focus-visible {
outline-offset: -2px;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon
iconName="more_horiz"
variant="filled"
$theme="brand"
$variation="secondary"
/>
</BoxButton>
</DropdownMenu>
{doc.abilities.children_create && (
<BoxButton
@@ -199,6 +227,13 @@ export const DocTreeItemActions = ({
$variation="secondary"
aria-label={t('Add a sub page')}
data-testid="doc-tree-item-actions-add-child"
tabIndex={-1}
$css={css`
&:focus-visible {
outline-offset: -2px;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon variant="filled" $color="inherit" iconName="add_box" />
</BoxButton>

View File

@@ -1,29 +0,0 @@
import { useEffect } from 'react';
export const useKeyboardActivation = (
keys: string[],
enabled: boolean,
action: () => void,
capture = false,
selector: string,
) => {
useEffect(() => {
if (!enabled) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
if (keys.includes(e.key)) {
e.preventDefault();
action();
}
};
const treeEl = document.querySelector<HTMLElement>(selector);
if (!treeEl) {
return;
}
treeEl.addEventListener('keydown', onKeyDown, capture);
return () => {
treeEl.removeEventListener('keydown', onKeyDown, capture);
};
}, [keys, enabled, action, capture, selector]);
};