From 54bcf64d43d28cbb2907abfb4589283eb3c37169 Mon Sep 17 00:00:00 2001 From: Ovgodd Date: Thu, 3 Sep 2026 17:07:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20keep=20the=20foc?= =?UTF-8?q?us=20on=20the=20doc=20options=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starring a sub page remounted the tree row and dropped the focus. --- .../__tests__/app-impress/doc-tree.spec.ts | 171 +++++++++++++++++- .../api/useCreateFavoriteDoc.tsx | 4 +- .../api/useDeleteFavoriteDoc.tsx | 4 +- .../doc-tree/components/DocSubPageItem.tsx | 45 ++--- .../components/DocTreeItemActions.tsx | 7 +- .../docs/doc-tree/components/DocTreeRoot.tsx | 76 ++++---- .../doc-tree/components/DocTreeSubpages.tsx | 51 ++++-- .../docs/doc-tree/hooks/useTreeItemActions.ts | 60 +----- .../src/features/docs/doc-tree/utils.ts | 82 ++++++++- 9 files changed, 343 insertions(+), 157 deletions(-) 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 2dbadc744..a5a4e5d5b 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 @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { Page, expect, test } from '@playwright/test'; import { clickInDocOptionMenu, @@ -13,8 +13,23 @@ import { clickOnAddRootSubPage, createRootSubPage, getTreeRow, + navigateToTopParentFromTree, } from './utils-sub-pages'; +/** Whether the doc title, inside the main content, holds the focus. */ +const isDocTitleFocused = (page: Page) => + page.evaluate(() => { + const active = document.activeElement; + const mainContent = document.getElementById('mainContent'); + + return ( + !!active && + !!mainContent && + mainContent.contains(active) && + active.classList.contains('--docs--doc-title') + ); + }); + test.describe('Doc Tree', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -433,6 +448,160 @@ test.describe('Doc Tree', () => { await verifyDocName(page, docParent); }); + test('it keeps the focus on the options button after starring a sub page', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-tree-star-focus', browserName, 1); + + const { name: subPageName } = await createRootSubPage( + page, + browserName, + 'doc-tree-star-focus-child', + ); + + const treeRow = await getTreeRow(page, subPageName); + await treeRow.hover(); + + const optionsButton = treeRow.getByRole('button', { + name: /Open the document options/i, + }); + + const starResponse = page.waitForResponse( + (response) => + response.url().includes('/favorite/') && + response.request().method() === 'POST', + ); + + await clickInDocOptionMenu(page, treeRow, 'Star'); + + // Closing the menu hands the focus back to the trigger. + await expect(optionsButton).toBeFocused(); + + // Syncing the starred doc into the tree happens a round trip later, and + // must not take the focus away. + await starResponse; + await expect(optionsButton).toBeFocused(); + }); + + test('it keeps the actions of a tree item visible while its menu is open', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-tree-menu-open', browserName, 1); + + const { name: subPageName } = await createRootSubPage( + page, + browserName, + 'doc-tree-menu-open-child', + ); + + const treeRow = await getTreeRow(page, subPageName); + const actions = treeRow.locator('.--docs--doc-tree-item-actions'); + + await page.mouse.move(0, 0); + await expect(actions).toHaveCSS('opacity', '0'); + + await treeRow.hover(); + await expect(actions).toHaveCSS('opacity', '1'); + + await treeRow + .getByRole('button', { name: /Open the document options/i }) + .click(); + await expect(page.getByRole('menuitem', { name: 'Star' })).toBeVisible(); + + // The pointer sits on the overlay and the focus in the menu portal, so + // only `data-menu-open` keeps the trigger on screen. + await page.mouse.move(0, 0); + await expect(actions).toHaveCSS('opacity', '1'); + }); + + test('check the aria structure of the doc tree', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-tree-aria', browserName, 1); + + const { name: subPageName } = await createRootSubPage( + page, + browserName, + 'doc-tree-aria-child', + ); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toHaveAttribute('role', 'tree'); + + // A tree cannot contain another tree: the sub pages are a group. + await expect(docTree.locator('[role="tree"]')).toHaveCount(0); + await expect(docTree.locator('[role="group"]').first()).toBeAttached(); + + // The keyboard hints only get announced from the tree's single Tab stop. + const rootItem = docTree.getByRole('treeitem', { name: /Root document/ }); + await expect(rootItem).toHaveAttribute('tabindex', '0'); + await expect(rootItem).toHaveAttribute( + 'aria-describedby', + 'doc-tree-keyboard-instructions', + ); + await expect(page.locator('#doc-tree-keyboard-instructions')).toHaveText( + /Press F2 to reach the actions of a document, then the left and right arrow keys/, + ); + + const treeRow = await getTreeRow(page, subPageName); + await treeRow.hover(); + const optionsButton = treeRow.getByRole('button', { + name: /Open the document options/i, + }); + await expect(optionsButton).toHaveAttribute('aria-haspopup', 'menu'); + await expect(optionsButton).toHaveAttribute('aria-expanded', 'false'); + + await optionsButton.click(); + await expect(optionsButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('it moves the focus to the doc content when pressing Enter on the current doc', async ({ + page, + browserName, + }) => { + const [docParent] = await createDoc( + page, + 'doc-tree-enter-focus', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + await docTree.getByRole('treeitem', { name: /Root document/ }).focus(); + await page.keyboard.press('Enter'); + + expect(await isDocTitleFocused(page)).toBe(true); + }); + + test('it leaves the focus alone when opening a doc with the pointer', async ({ + page, + browserName, + }) => { + const [docParent] = await createDoc( + page, + 'doc-tree-pointer-focus', + browserName, + 1, + ); + + await createRootSubPage(page, browserName, 'doc-tree-pointer-focus-child'); + + // Any key press used to mark every navigation that followed as a keyboard + // one, which sent the focus into the content of the doc being opened. + await page.keyboard.press('Tab'); + + await navigateToTopParentFromTree({ page }); + await verifyDocName(page, docParent); + + expect(await isDocTitleFocused(page)).toBe(false); + }); + test('it updates the child icon from the tree', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx index 70647aaf4..8c8881d1f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { syncDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils'; +import { patchDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils'; import { Doc } from '../types'; @@ -44,7 +44,7 @@ export function useCreateFavoriteDoc({ }); }); - syncDocInTree(treeContext, id, { is_favorite: true }); + patchDocInTree(treeContext, id, { is_favorite: true }); const message = t('Document starred successfully!'); announce(message, 'polite'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx index fc61f7589..f3385cff9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { syncDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils'; +import { patchDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils'; import { Doc } from '../types'; @@ -44,7 +44,7 @@ export function useDeleteFavoriteDoc({ }); }); - syncDocInTree(treeContext, id, { is_favorite: false }); + patchDocInTree(treeContext, id, { is_favorite: false }); const message = t('Document unstarred successfully!'); announce(message, 'polite'); 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 57d68d561..8bebf08f0 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 @@ -21,7 +21,7 @@ import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; import { useTreeItemActions } from '../hooks/useTreeItemActions'; -import { isDocNode } from '../utils'; +import { isDocNode, treeItemActionsRevealCss } from '../utils'; import SubPageIcon from './../assets/sub-page-logo.svg'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -114,31 +114,16 @@ const DocSubPageItemContent = (props: TreeViewNodeProps) => { 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. - */ + // react-arborist only moves the DOM focus when `isFocused` changes, and it + // is already true while one of this row's buttons holds the focus. 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 { isMenuOpen, onMenuOpenChange, handleActionsKeyDown } = + useTreeItemActions({ + focusItem: focusRow, + }); const afterCreate = (createdDoc: Doc) => { const actualChildren = node.data.children ?? []; @@ -177,9 +162,9 @@ const DocSubPageItemContent = (props: TreeViewNodeProps) => { return ( ) => { display: block; width: 100%; border-radius: var(--c--globals--spacings--st); + ${treeItemActionsRevealCss} + .c__tree-view--node { padding-right: var(--c--globals--spacings--xxxs); height: 32px; @@ -285,13 +272,11 @@ const DocSubPageItemContent = (props: TreeViewNodeProps) => { {displayTitle} - {areActionsVisible && ( - - )} + ); 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 e55cfbf55..e72d3e131 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 @@ -9,7 +9,10 @@ import { Doc, useCreateChildDoc, useTrans } from '@/docs/doc-management'; import { DocToolBox } from '@/docs/doc-management/components/DocToolBox'; import MoreIcon from '@/icons/more_horiz.svg'; -import { CLASS_TREE_ITEM_ACTIONS } from '../utils'; +import { + CLASS_TREE_ITEM_ACTIONS, + CLASS_TREE_ITEM_ACTIONS_WRAPPER, +} from '../utils'; // Module-level so the reference stays stable and the memoized `DocToolBox` // isn't forced to re-render on every parent render. @@ -47,7 +50,7 @@ export const DocTreeItemActions = ({ $direction="row" $align="center" $gap="4xs" - className={`${CLASS_TREE_ITEM_ACTIONS} --docs--doc-tree-item-actions actions`} + className={`${CLASS_TREE_ITEM_ACTIONS} ${CLASS_TREE_ITEM_ACTIONS_WRAPPER} actions`} role="toolbar" aria-label={t('Actions for {{title}}', { title: doc.title || untitledDocument, 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 index 792f34bc4..676ff4444 100644 --- 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 @@ -7,11 +7,15 @@ 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 { focusMainContentStart } from '@/layouts/utils'; import { useResponsiveStore } from '@/stores/useResponsiveStore'; -import { CLASS_DOC_TITLE } from '../../doc-header'; import { useTreeItemActions } from '../hooks/useTreeItemActions'; -import { isWithinTreeItemActions } from '../utils'; +import { + ID_TREE_KEYBOARD_INSTRUCTIONS, + isWithinTreeItemActions, + treeItemActionsRevealCss, +} from '../utils'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -47,15 +51,10 @@ export const DocTreeRoot = ({ rootItemRef.current?.focus(); }, [rootItemRef]); - const { - areActionsVisible, - isMenuOpen, - onMenuOpenChange, - handleActionsKeyDown, - itemProps, - } = useTreeItemActions({ - focusItem: focusRootItem, - }); + const { isMenuOpen, onMenuOpenChange, handleActionsKeyDown } = + useTreeItemActions({ + focusItem: focusRootItem, + }); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -66,18 +65,13 @@ export const DocTreeRoot = ({ 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. + // ArrowDown enters the sub pages; ArrowUp has nowhere to go 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`. + // Same reason as `focusRow` in `DocSubPageItem`. rootItemRef.current ?.closest('[data-testid="doc-tree"]') ?.querySelector( @@ -97,9 +91,10 @@ export const DocTreeRoot = ({ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - // Already on this document: move on to its title rather than reloading. + // Already on this document: move on to its content rather than + // reloading. if (currentDoc.id === root?.id) { - document.querySelector(`.${CLASS_DOC_TITLE}`)?.focus(); + focusMainContentStart(); } else if (root) { selectRoot(); void router.push(`/docs/${root.id}`); @@ -125,11 +120,12 @@ export const DocTreeRoot = ({ return ( - {areActionsVisible && ( - { - const newDoc = { - ...createdDoc, - children: [], - childrenCount: 0, - parentId: root.id, - }; - treeContext.treeData.addChild(null, newDoc); + { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: root.id, + }; + treeContext.treeData.addChild(null, newDoc); - if (isMobile) { - closePanel(); - } - }} - /> - )} + 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 index 6635593af..933ef69c0 100644 --- 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 @@ -11,15 +11,21 @@ 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 { focusMainContentStart } from '@/layouts/utils'; import { isDocNode, isWithinTreeItemActions } from '../utils'; import { DocSubPageItem } from './DocSubPageItem'; +/** + * Scalars rather than the current `Doc`: refetching it yields an equal but new + * object, which would break the memo and re-render the tree. That re-inserts + * the rows' DOM nodes and drops the focus held inside them. + */ interface DocTreeSubPagesProps { - doc: Doc; + canMoveInto: boolean; + isDeleted: boolean; treeRoot: HTMLElement; initialOpenState: OpenMap; rootNodeId: string; @@ -27,7 +33,8 @@ interface DocTreeSubPagesProps { } export const DocTreeSubpages = memo(function DocTreeSubpages({ - doc, + canMoveInto, + isDeleted, treeRoot, initialOpenState, rootNodeId, @@ -39,17 +46,22 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ 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. + * react-arborist hardcodes `tabIndex=0` and `role="tree"` on its container, + * and `ui-components` does not forward `renderContainer`. The root item is + * the tree's only Tab stop, and `DocTree` already owns the `tree` role, so + * this container is only the root item's `group`. */ useEffect(() => { - treeRoot - .querySelector('.c__tree-view--container [role="tree"]') - ?.setAttribute('tabindex', '-1'); + const container = treeRoot.querySelector( + '.c__tree-view--container [role="tree"], .c__tree-view--container [role="group"]', + ); + + if (!container) { + return; + } + + container.setAttribute('tabindex', '-1'); + container.setAttribute('role', 'group'); }, [treeRoot]); const handleMove = useCallback( @@ -69,11 +81,11 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ ({ parentNode }: { parentNode: NodeApi> | null }) => { const parentValue = parentNode?.data.value; if (!parentValue || !isDocNode(parentValue)) { - return doc.abilities.move && isDesktop; + return canMoveInto && isDesktop; } return parentValue.abilities.move && isDesktop; }, - [doc.abilities.move, isDesktop], + [canMoveInto, isDesktop], ); const canDrag = useCallback( @@ -125,11 +137,12 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ 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') { + // Already on this document: move on to its content rather than reloading. + // `role="treeitem"` sits on the row itself — react-arborist puts it there + // through `rowClassName` — so this reads the row, not a descendant. + if (e.currentTarget.getAttribute('aria-selected') === 'true') { e.preventDefault(); - document.querySelector(`.${CLASS_DOC_TITLE}`)?.focus(); + focusMainContentStart(); return; } @@ -141,7 +154,7 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({ ); return ( - + void; focusItem: () => void; }; type UseTreeItemActionsReturn = { - /** Whether `DocTreeItemActions` should be rendered for this item. */ - areActionsVisible: boolean; isMenuOpen: boolean; onMenuOpenChange: (isOpen: boolean) => void; /** @@ -33,13 +20,6 @@ type UseTreeItemActionsReturn = { * 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. */ @@ -49,24 +29,11 @@ const getActionButtons = (container: HTMLElement) => !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. - */ +/** Keyboard navigation inside a tree item's actions. */ 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); @@ -75,7 +42,7 @@ export const useTreeItemActions = ({ const handleActionsKeyDown = useCallback( (event: KeyboardEvent) => { // While the menu is open the keyboard belongs to it: Escape closes it and - // `onMenuOpenChange` restores focus from there. + // React Aria restores the focus to the trigger from there. if (isMenuOpen) { return false; } @@ -119,30 +86,9 @@ export const useTreeItemActions = ({ [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 1847a4748..062889436 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 @@ -6,11 +6,53 @@ import { TreeViewNodeTypeEnum, } from '@gouvfr-lasuite/ui-components'; import { useContext } from 'react'; +import { css } from 'styled-components'; -import { Doc } from '../doc-management'; +// Type-only: a value import would create a cycle through `doc-management`, +// leaving the constants below undefined at render time. +import type { Doc } from '../doc-management'; export const CLASS_TREE_ITEM_ACTIONS = 'doc-tree-root-item-actions'; +/** Wraps a tree item's action buttons, revealed by CSS on hover / focus. */ +export const CLASS_TREE_ITEM_ACTIONS_WRAPPER = '--docs--doc-tree-item-actions'; + +export const ID_TREE_KEYBOARD_INSTRUCTIONS = 'doc-tree-keyboard-instructions'; + +/** + * Reveals a tree item's actions, to apply on the item itself. They stay + * mounted, so that closing the options menu always has a live trigger to give + * the focus back to; `opacity` rather than `visibility`, which would keep F2 + * from reaching them. + * + * `data-menu-open` covers the open menu, whose focus sits in a portal, and the + * `c__tree-view--row` ancestor the sub pages, focused on the row rather than on + * the item. + */ +export const treeItemActionsRevealCss = css` + .${CLASS_TREE_ITEM_ACTIONS_WRAPPER} { + opacity: 0; + pointer-events: none; + } + + &:hover, + &:focus-within, + &[data-menu-open], + .c__tree-view--row:focus-within & { + .${CLASS_TREE_ITEM_ACTIONS_WRAPPER} { + opacity: 1; + pointer-events: auto; + } + } + + @media (hover: none) { + .${CLASS_TREE_ITEM_ACTIONS_WRAPPER} { + opacity: 1; + pointer-events: auto; + } + } +`; + export const isWithinTreeItemActions = (event: React.SyntheticEvent) => !!(event.target as HTMLElement | null)?.closest( `.${CLASS_TREE_ITEM_ACTIONS}`, @@ -45,6 +87,9 @@ export const useTreeContextOrNull = (): TreeContextType | null => * * No-op when there is no tree context (e.g. the doc grid or the doc header), * where the react-query cache is the single source of truth. + * + * Data only: it runs long after the interaction, so it must never move the + * focus. */ export const syncDocInTree = ( treeContext: TreeContextType | null, @@ -58,11 +103,40 @@ export const syncDocInTree = ( const { root } = treeContext; if (root && root.id === docId) { treeContext.setRoot({ ...root, ...data }); - } else if (treeContext.treeData.getNode(docId)) { - treeContext.treeData.updateNode(docId, data); + return; } - treeContext.treeApiRef.current?.focus(docId); + if (treeContext.treeData.getNode(docId)) { + treeContext.treeData.updateNode(docId, data); + } +}; + +/** + * Same as `syncDocInTree`, for fields the tree does not display. + * + * `updateNode` re-renders the row, which re-inserts its DOM node and drops the + * focus inside it. Mutating the value skips the render: the options menu is the + * only reader, and it is remounted on each open. + */ +export const patchDocInTree = ( + treeContext: TreeContextType | null, + docId: string, + data: Partial, +) => { + if (!treeContext) { + return; + } + + const { root } = treeContext; + if (root && root.id === docId) { + treeContext.setRoot({ ...root, ...data }); + return; + } + + const node = treeContext.treeData.getNode(docId); + if (node) { + Object.assign(node, data); + } }; export const reloadTree = (treeContext: TreeContextType | null) => {