️(frontend) keep the focus on the doc options button

Starring a sub page remounted the tree row and dropped the focus.
This commit is contained in:
Ovgodd
2026-09-03 17:07:02 +02:00
parent b12b3b5dd6
commit 54bcf64d43
9 changed files with 343 additions and 157 deletions
@@ -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,
@@ -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');
@@ -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');
@@ -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<Doc>) => {
const itemRef = useRef<HTMLAnchorElement>(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<HTMLElement>('.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<Doc>) => {
return (
<StyledLink
{...itemProps}
ref={itemRef}
className="--docs-sub-page-item"
data-menu-open={isMenuOpen || undefined}
/**
* Conflict with the react-arborist DND.
* It should be disabled to have the DND working properly.
@@ -223,6 +208,8 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
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<Doc>) => {
{displayTitle}
</Text>
</Box>
{areActionsVisible && (
<DocTreeItemActions
doc={doc}
onOpenChange={onMenuOpenChange}
onCreateSuccess={afterCreate}
/>
)}
<DocTreeItemActions
doc={doc}
onOpenChange={onMenuOpenChange}
onCreateSuccess={afterCreate}
/>
</TreeViewItem>
</StyledLink>
);
@@ -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,
@@ -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<HTMLElement>) => {
@@ -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<HTMLElement>(
@@ -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<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
focusMainContentStart();
} else if (root) {
selectRoot();
void router.push(`/docs/${root.id}`);
@@ -125,11 +120,12 @@ export const DocTreeRoot = ({
return (
<Box
{...itemProps}
ref={rootItemRef}
data-testid="doc-tree-root-item"
data-menu-open={isMenuOpen || undefined}
role="treeitem"
aria-label={t('Root document {{title}}', { title })}
aria-describedby={ID_TREE_KEYBOARD_INSTRUCTIONS}
aria-selected={isSelected}
tabIndex={0}
onKeyDown={handleKeyDown}
@@ -160,6 +156,8 @@ export const DocTreeRoot = ({
&:has(.doc-tree-root-item-actions *:focus) {
box-shadow: none !important;
}
${treeItemActionsRevealCss}
`}
>
<StyledLink
@@ -182,25 +180,23 @@ export const DocTreeRoot = ({
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={root} showDate={true} />
{areActionsVisible && (
<DocTreeItemActions
doc={root}
onOpenChange={onMenuOpenChange}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: root.id,
};
treeContext.treeData.addChild(null, newDoc);
<DocTreeItemActions
doc={root}
onOpenChange={onMenuOpenChange}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: root.id,
};
treeContext.treeData.addChild(null, newDoc);
if (isMobile) {
closePanel();
}
}}
/>
)}
if (isMobile) {
closePanel();
}
}}
/>
</Box>
</StyledLink>
</Box>
@@ -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<HTMLElement>('.c__tree-view--container [role="tree"]')
?.setAttribute('tabindex', '-1');
const container = treeRoot.querySelector<HTMLElement>(
'.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<TreeDataItem<Doc>> | 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<HTMLElement>(`.${CLASS_DOC_TITLE}`)?.focus();
focusMainContentStart();
return;
}
@@ -141,7 +154,7 @@ export const DocTreeSubpages = memo(function DocTreeSubpages({
);
return (
<Overlayer isOverlay={doc.deleted_at != null} inert>
<Overlayer isOverlay={isDeleted} inert>
<TreeView
dndRootElement={treeRoot}
initialOpenState={initialOpenState}
@@ -1,25 +1,12 @@
import {
FocusEvent,
HTMLAttributes,
KeyboardEvent,
useCallback,
useMemo,
useState,
} from 'react';
import { useResponsiveStore } from '@/stores';
import { KeyboardEvent, useCallback, useState } from 'react';
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;
/**
@@ -33,13 +20,6 @@ type UseTreeItemActionsReturn = {
* Returns whether the event was handled, so the caller can stop there.
*/
handleActionsKeyDown: (event: KeyboardEvent<HTMLElement>) => boolean;
/** Spread on the element wrapping the item and its actions. */
itemProps: Required<
Pick<
HTMLAttributes<HTMLElement>,
'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<HTMLElement>) => {
// 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<HTMLElement>) => {
// 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,
};
};
@@ -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 = <T = Doc>(): TreeContextType<T> | 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<Doc> | 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<Doc> | null,
docId: string,
data: Partial<Doc>,
) => {
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<Doc | null> | null) => {