diff --git a/CHANGELOG.md b/CHANGELOG.md index 36af2298a4..fedb175669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 d43a174923..39af64097b 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 @@ -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, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 89325c3060..e87f4d1f8b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -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'; @@ -92,20 +92,43 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { } }; + 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(null); + const buttonOptionRef = useRef(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 ( ) => { 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 +151,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { 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 +169,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { display: flex; } } + &:focus-within { + .light-doc-item-actions { + display: flex; + } + } .row.preview & { background-color: inherit; } @@ -152,7 +189,36 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $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); + } + `, + }} /> + + + { e.stopPropagation(); @@ -196,21 +262,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { )} - - - ); 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 fcac0b9dc4..6b51c9d275 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 @@ -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(null); + const rootActionsRef = useRef(null); + const rootButtonOptionRef = useRef(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 */} + + {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.', + )} + { `} > { } .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; + } `} > { }} isOpen={rootActionsOpen} isRoot={true} - onOpenChange={setRootActionsOpen} + onOpenChange={handleRootActionsOpenChange} + actionsRef={rootActionsRef} + buttonOptionRef={rootButtonOptionRef} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 4ce70ef6a8..c1a5b84188 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -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; + buttonOptionRef?: React.RefObject; }; export const DocTreeItemActions = ({ @@ -39,7 +43,13 @@ export const DocTreeItemActions = ({ onCreateSuccess, onOpenChange, parentId, + actionsRef, + buttonOptionRef, }: DocTreeItemActionsProps) => { + const internalActionsRef = useRef(null); + const targetActionsRef = actionsRef ?? internalActionsRef; + const internalButtonRef = useRef(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(); + // 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 ( - + - { 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); + } + `} + > + + {doc.abilities.children_create && ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts index 0a29aaf552..c768d9114e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts @@ -11,17 +11,43 @@ export const useKeyboardActivation = ( if (!enabled) { return; } + const onKeyDown = (e: KeyboardEvent) => { - if (keys.includes(e.key)) { - e.preventDefault(); - action(); + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + + // Limit activation to doc tree rows that actually contain a DocSubPageItem + const row = target.closest('.c__tree-view--row'); + if (!row || !row.querySelector('.--docs-sub-page-item')) { + return; } + + // Do not hijack Enter/Space when focus is on emoji button or actions toolbar: + // in these cases we want the native button / dropdown behavior. + if ( + target.closest('.--docs--doc-icon') || + target.closest('.light-doc-item-actions') + ) { + return; + } + + if (!keys.includes(e.key)) { + return; + } + + e.preventDefault(); + action(); }; + const treeEl = document.querySelector(selector); if (!treeEl) { return; } + treeEl.addEventListener('keydown', onKeyDown, capture); + return () => { treeEl.removeEventListener('keydown', onKeyDown, capture); };