mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-10 11:47:52 +02:00
♿️(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:
@@ -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,
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
+15
-30
@@ -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>
|
||||
);
|
||||
|
||||
+5
-2
@@ -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>
|
||||
|
||||
+32
-19
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user