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 5728615dd..2dbadc744 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 @@ -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 ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts index eba22444d..c822674a6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts @@ -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) => { diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 042d2f64d..c8947e39b 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -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(null); - const addLastFocus = useFocusStore((state) => state.addLastFocus); - - useEffect(() => { - addLastFocus(inputRef.current); - }, [addLastFocus]); if (children) { return ( 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 fade9ef07..57d68d561 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 @@ -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) => { const treeContext = useTreeContext(); 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(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('.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) => { const isCurrentPage = router.query?.id === doc.id; const isDeleted = !!doc.deleted_at; - const actionsRef = useRef(null); - const buttonOptionRef = useRef(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 ( ) => { } 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) => { 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) => { .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) => { 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) => { {displayTitle} - + {areActionsVisible && ( - + )} ); 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 5d1f91847..739169f5d 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 @@ -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(null); const treeContext = useTreeContext(); - const router = useRouter(); - const [rootActionsOpen, setRootActionsOpen] = useState(false); - 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(); - const [initialOpenState, setInitialOpenState] = useState( 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(`.${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 */} {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.', )} { z-index: 2; `} > - - { - 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 - > - - - { - 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} - /> - - - + {initialOpenState && treeContext.treeData.nodes.length > 0 && treeRoot && ( - )} ); }; - -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(); - 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> | 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) => { - 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(`.${CLASS_DOC_TITLE}`)?.focus(); - return; - } - - e.currentTarget - .querySelector('.c__tree-view--node') - ?.click(); - }, - [rootItem], - ); - - return ( - - - - ); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeRoot.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeRoot.tsx new file mode 100644 index 000000000..792f34bc4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeRoot.tsx @@ -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; + treeContext: TreeContextType; +}; + +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) => { + // 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( + `[data-testid="doc-sub-page-item-${firstNode.id}"]`, + ) + ?.closest('.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(`.${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 ( + + { + 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 + > + + + {areActionsVisible && ( + { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: root.id, + }; + treeContext.treeData.addChild(null, newDoc); + + if (isMobile) { + closePanel(); + } + }} + /> + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx new file mode 100644 index 000000000..6635593af --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeSubpages.tsx @@ -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; +} + +export const DocTreeSubpages = memo(function DocTreeSubpages({ + doc, + treeRoot, + initialOpenState, + rootNodeId, + rootItemRef, +}: DocTreeSubPagesProps) { + const { isDesktop } = useResponsive(); + const treeContext = useTreeContext(); + 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('.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> | 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) => { + 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(`.${CLASS_DOC_TITLE}`)?.focus(); + return; + } + + e.currentTarget + .querySelector('.c__tree-view--node') + ?.click(); + }, + [rootItemRef, treeContext], + ); + + return ( + + + + ); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeItemActions.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeItemActions.ts new file mode 100644 index 000000000..a325df5eb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeItemActions.ts @@ -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) => boolean; + /** Spread on the element wrapping the item and its actions. */ + itemProps: Required< + Pick< + HTMLAttributes, + 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur' + > + >; +}; + +/** Focusable action buttons inside `container`, in DOM order. */ +const getActionButtons = (container: HTMLElement) => + Array.from(container.querySelectorAll('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) => { + // 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) => { + // 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, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts index 36e377275..aaf4d8051 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -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 = (