✨(frontend) keyboard support in sub-documents with f2 options access
adds f2 shortcut to open options menu in sub-documents Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) adds f2 shortcut using a fakenode since it's outside the treeview Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) add sr-only instructions with aria-describedby links improves screen reader support with contextual accessibility guidance Signed-off-by: Cyril <c.gromoff@gmail.com> ✅(frontend) add e2e test to check focus behavior with F2 shortcut ensures F2 correctly focuses the expected UI element Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -18,6 +18,8 @@ and this project adheres to
|
|||||||
- 🛂(backend) stop throttling collaboration servers #1730
|
- 🛂(backend) stop throttling collaboration servers #1730
|
||||||
- 🚸(backend) use unaccented full name for user search #1637
|
- 🚸(backend) use unaccented full name for user search #1637
|
||||||
- 🌐(backend) internationalize demo #1644
|
- 🌐(backend) internationalize demo #1644
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿️Improve keyboard accessibility for the document tree #1681
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,40 @@ test.describe('Doc Tree', () => {
|
|||||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation with F2 focuses root actions button', async ({
|
||||||
|
page,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
// Create a parent document to initialize the tree
|
||||||
|
const [docParent] = await createDoc(
|
||||||
|
page,
|
||||||
|
'doc-tree-keyboard-f2-root',
|
||||||
|
browserName,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await verifyDocName(page, docParent);
|
||||||
|
|
||||||
|
const docTree = page.getByTestId('doc-tree');
|
||||||
|
await expect(docTree).toBeVisible();
|
||||||
|
|
||||||
|
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||||
|
await expect(rootItem).toBeVisible();
|
||||||
|
|
||||||
|
// Focus the root item
|
||||||
|
await rootItem.focus();
|
||||||
|
await expect(rootItem).toBeFocused();
|
||||||
|
|
||||||
|
// Press F2 → focus should move to the root actions \"More options\" button
|
||||||
|
await page.keyboard.press('F2');
|
||||||
|
|
||||||
|
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
|
||||||
|
const rootMoreOptionsButton = rootActions.getByRole('button', {
|
||||||
|
name: /more options/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(rootMoreOptionsButton).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
test('it updates the child icon from the tree', async ({
|
test('it updates the child icon from the tree', async ({
|
||||||
page,
|
page,
|
||||||
browserName,
|
browserName,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
useTreeContext,
|
useTreeContext,
|
||||||
} from '@gouvfr-lasuite/ui-kit';
|
} from '@gouvfr-lasuite/ui-kit';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
import { useLeftPanelStore } from '@/features/left-panel';
|
import { useLeftPanelStore } from '@/features/left-panel';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
|
|
||||||
|
|
||||||
import SubPageIcon from './../assets/sub-page-logo.svg';
|
import SubPageIcon from './../assets/sub-page-logo.svg';
|
||||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
|
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
|
||||||
const isActive = node.isFocused || menuOpen || isSelectedNow;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { togglePanel } = useLeftPanelStore();
|
const { togglePanel } = useLeftPanelStore();
|
||||||
@@ -92,20 +89,34 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardActivation(
|
|
||||||
['Enter'],
|
|
||||||
isActive && !menuOpen,
|
|
||||||
handleActivate,
|
|
||||||
true,
|
|
||||||
'.c__tree-view',
|
|
||||||
);
|
|
||||||
|
|
||||||
const docTitle = doc.title || untitledDocument;
|
const docTitle = doc.title || untitledDocument;
|
||||||
const hasChildren = (doc.children?.length || 0) > 0;
|
const hasChildren = (doc.children?.length || 0) > 0;
|
||||||
const isExpanded = node.isOpen;
|
const isExpanded = node.isOpen;
|
||||||
const isSelected = isSelectedNow;
|
const isSelected = isSelectedNow;
|
||||||
const ariaLabel = docTitle;
|
const ariaLabel = docTitle;
|
||||||
const isDisabled = !!doc.deleted_at;
|
const isDisabled = !!doc.deleted_at;
|
||||||
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// F2: focus first action button
|
||||||
|
const shouldOpenActions = !menuOpen && node.isFocused;
|
||||||
|
if (e.key === 'F2' && shouldOpenActions) {
|
||||||
|
buttonOptionRef.current?.focus();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionsOpenChange = (isOpen: boolean) => {
|
||||||
|
setMenuOpen(isOpen);
|
||||||
|
|
||||||
|
// When the menu closes (via Escape or activating an option),
|
||||||
|
// return focus to the tree item so focus is not lost.
|
||||||
|
if (!isOpen) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -117,6 +128,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={isDisabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
$css={css`
|
$css={css`
|
||||||
background-color: var(--c--globals--colors--gray-000);
|
background-color: var(--c--globals--colors--gray-000);
|
||||||
.light-doc-item-actions {
|
.light-doc-item-actions {
|
||||||
@@ -127,6 +139,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
|
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
|
||||||
border-radius: var(--c--globals--spacings--st);
|
border-radius: var(--c--globals--spacings--st);
|
||||||
|
.light-doc-item-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Remove visual focus from the tree item when focus is on actions or emoji button */
|
||||||
|
&:has(.light-doc-item-actions *:focus, .--docs--doc-icon:focus-visible)
|
||||||
|
.c__tree-view--node.isFocused {
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(
|
background-color: var(
|
||||||
@@ -137,6 +157,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&:focus-within {
|
||||||
|
.light-doc-item-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
.row.preview & {
|
.row.preview & {
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
@@ -152,7 +177,36 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
$size="sm"
|
$size="sm"
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
title={doc.title}
|
title={doc.title}
|
||||||
|
buttonProps={{
|
||||||
|
$css: css`
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--c--globals--colors--brand-500);
|
||||||
|
outline-offset: var(--c--globals--spacings--4xs);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Box
|
||||||
|
$direction="row"
|
||||||
|
$align="center"
|
||||||
|
className="light-doc-item-actions actions"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
|
||||||
|
$css={css`
|
||||||
|
margin-left: auto;
|
||||||
|
order: 2;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<DocTreeItemActions
|
||||||
|
doc={doc}
|
||||||
|
isOpen={menuOpen}
|
||||||
|
onOpenChange={handleActionsOpenChange}
|
||||||
|
parentId={node.data.parentKey}
|
||||||
|
onCreateSuccess={afterCreate}
|
||||||
|
actionsRef={actionsRef}
|
||||||
|
buttonOptionRef={buttonOptionRef}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<BoxButton
|
<BoxButton
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -196,21 +250,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</BoxButton>
|
</BoxButton>
|
||||||
<Box
|
|
||||||
$direction="row"
|
|
||||||
$align="center"
|
|
||||||
className="light-doc-item-actions"
|
|
||||||
role="toolbar"
|
|
||||||
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
|
|
||||||
>
|
|
||||||
<DocTreeItemActions
|
|
||||||
doc={doc}
|
|
||||||
isOpen={menuOpen}
|
|
||||||
onOpenChange={setMenuOpen}
|
|
||||||
parentId={node.data.parentKey}
|
|
||||||
onCreateSuccess={afterCreate}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</TreeViewItem>
|
</TreeViewItem>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useTreeContext,
|
useTreeContext,
|
||||||
} from '@gouvfr-lasuite/ui-kit';
|
} from '@gouvfr-lasuite/ui-kit';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
@@ -35,6 +35,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
const rootIsSelected =
|
const rootIsSelected =
|
||||||
!!treeContext?.root?.id &&
|
!!treeContext?.root?.id &&
|
||||||
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
||||||
|
const rootItemRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootActionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -88,18 +91,45 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
selectRoot();
|
selectRoot();
|
||||||
}, [selectRoot]);
|
}, [selectRoot]);
|
||||||
|
|
||||||
// activate root document with enter or space
|
// Handle keyboard navigation for root item
|
||||||
const handleRootKeyDown = useCallback(
|
const handleRootKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
// F2: focus first action button
|
||||||
|
if (e.key === 'F2' && !rootActionsOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
rootButtonOptionRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore if focus is in actions
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('.doc-tree-root-item-actions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRoot();
|
selectRoot();
|
||||||
navigateToRoot();
|
navigateToRoot();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectRoot, navigateToRoot],
|
[selectRoot, navigateToRoot, rootActionsOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle menu open/close for root item - mirrors DocSubPageItem behavior
|
||||||
|
const handleRootActionsOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
setRootActionsOpen(isOpen);
|
||||||
|
|
||||||
|
// When the menu closes, return focus to the root tree item
|
||||||
|
// (same behavior as DocSubPageItem for consistency)
|
||||||
|
// Use requestAnimationFrame for smoother focus transition without flickering
|
||||||
|
if (!isOpen) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rootItemRef.current?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This effect is used to reset the tree when a new document
|
* This effect is used to reset the tree when a new document
|
||||||
* that is not part of the current tree is loaded.
|
* that is not part of the current tree is loaded.
|
||||||
@@ -180,6 +210,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
$height="100%"
|
$height="100%"
|
||||||
role="tree"
|
role="tree"
|
||||||
aria-label={t('Document tree')}
|
aria-label={t('Document tree')}
|
||||||
|
aria-describedby="doc-tree-keyboard-instructions"
|
||||||
$css={css`
|
$css={css`
|
||||||
/* Remove outline from TreeViewItem wrapper elements */
|
/* Remove outline from TreeViewItem wrapper elements */
|
||||||
.c__tree-view--row {
|
.c__tree-view--row {
|
||||||
@@ -199,6 +230,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
{/* Keyboard instructions for screen readers */}
|
||||||
|
<Box id="doc-tree-keyboard-instructions" className="sr-only">
|
||||||
|
{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.',
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
|
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
|
||||||
$css={css`
|
$css={css`
|
||||||
@@ -206,6 +243,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
ref={rootItemRef}
|
||||||
data-testid="doc-tree-root-item"
|
data-testid="doc-tree-root-item"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
|
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
|
||||||
@@ -234,7 +272,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doc-tree-root-item-actions {
|
.doc-tree-root-item-actions {
|
||||||
display: ${rootActionsOpen ? 'flex' : 'none'};
|
display: flex;
|
||||||
opacity: ${rootActionsOpen ? '1' : '0'};
|
opacity: ${rootActionsOpen ? '1' : '0'};
|
||||||
|
|
||||||
&:has(.isOpen) {
|
&:has(.isOpen) {
|
||||||
@@ -242,12 +280,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible,
|
||||||
|
&:focus-within {
|
||||||
.doc-tree-root-item-actions {
|
.doc-tree-root-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Remove visual focus from the root item when focus is on the actions */
|
||||||
|
&:has(.doc-tree-root-item-actions *:focus) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<StyledLink
|
<StyledLink
|
||||||
@@ -281,7 +324,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
|||||||
}}
|
}}
|
||||||
isOpen={rootActionsOpen}
|
isOpen={rootActionsOpen}
|
||||||
isRoot={true}
|
isRoot={true}
|
||||||
onOpenChange={setRootActionsOpen}
|
onOpenChange={handleRootActionsOpenChange}
|
||||||
|
actionsRef={rootActionsRef}
|
||||||
|
buttonOptionRef={rootButtonOptionRef}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuOption,
|
DropdownMenuOption,
|
||||||
|
useArrowRoving,
|
||||||
useTreeContext,
|
useTreeContext,
|
||||||
} from '@gouvfr-lasuite/ui-kit';
|
} from '@gouvfr-lasuite/ui-kit';
|
||||||
import { useModal } from '@openfun/cunningham-react';
|
import { useModal } from '@openfun/cunningham-react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ type DocTreeItemActionsProps = {
|
|||||||
onCreateSuccess?: (newDoc: Doc) => void;
|
onCreateSuccess?: (newDoc: Doc) => void;
|
||||||
onOpenChange?: (isOpen: boolean) => void;
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
actionsRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocTreeItemActions = ({
|
export const DocTreeItemActions = ({
|
||||||
@@ -39,7 +43,13 @@ export const DocTreeItemActions = ({
|
|||||||
onCreateSuccess,
|
onCreateSuccess,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
parentId,
|
parentId,
|
||||||
|
actionsRef,
|
||||||
|
buttonOptionRef,
|
||||||
}: DocTreeItemActionsProps) => {
|
}: DocTreeItemActionsProps) => {
|
||||||
|
const internalActionsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const targetActionsRef = actionsRef ?? internalActionsRef;
|
||||||
|
const internalButtonRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteModal = useModal();
|
const deleteModal = useModal();
|
||||||
@@ -47,6 +57,9 @@ export const DocTreeItemActions = ({
|
|||||||
const { mutate: detachDoc } = useDetachDoc();
|
const { mutate: detachDoc } = useDetachDoc();
|
||||||
const treeContext = useTreeContext<Doc | null>();
|
const treeContext = useTreeContext<Doc | null>();
|
||||||
|
|
||||||
|
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
|
||||||
|
useArrowRoving(targetActionsRef.current);
|
||||||
|
|
||||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||||
onSuccess: (duplicatedDoc) => {
|
onSuccess: (duplicatedDoc) => {
|
||||||
// Reset the tree context root will reset the full tree view.
|
// Reset the tree context root will reset the full tree view.
|
||||||
@@ -160,30 +173,45 @@ export const DocTreeItemActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="doc-tree-root-item-actions">
|
<Box className="doc-tree-root-item-actions actions">
|
||||||
<Box
|
<Box
|
||||||
|
ref={targetActionsRef}
|
||||||
$direction="row"
|
$direction="row"
|
||||||
$align="center"
|
$align="center"
|
||||||
className="--docs--doc-tree-item-actions"
|
className="--docs--doc-tree-item-actions"
|
||||||
$gap="4px"
|
$gap="4px"
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
options={options}
|
options={options}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
>
|
>
|
||||||
<Icon
|
<BoxButton
|
||||||
|
ref={targetButtonRef}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpenChange?.(!isOpen);
|
onOpenChange?.(!isOpen);
|
||||||
}}
|
}}
|
||||||
iconName="more_horiz"
|
aria-label={t('More options')}
|
||||||
variant="filled"
|
tabIndex={-1}
|
||||||
$theme="brand"
|
$theme="brand"
|
||||||
$variation="secondary"
|
$variation="secondary"
|
||||||
aria-label={t('More options')}
|
$css={css`
|
||||||
/>
|
&:focus-visible {
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: var(--c--globals--spacings--st);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
iconName="more_horiz"
|
||||||
|
variant="filled"
|
||||||
|
$theme="brand"
|
||||||
|
$variation="secondary"
|
||||||
|
/>
|
||||||
|
</BoxButton>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{doc.abilities.children_create && (
|
{doc.abilities.children_create && (
|
||||||
<BoxButton
|
<BoxButton
|
||||||
@@ -199,6 +227,13 @@ export const DocTreeItemActions = ({
|
|||||||
$variation="secondary"
|
$variation="secondary"
|
||||||
aria-label={t('Add a sub page')}
|
aria-label={t('Add a sub page')}
|
||||||
data-testid="doc-tree-item-actions-add-child"
|
data-testid="doc-tree-item-actions-add-child"
|
||||||
|
tabIndex={-1}
|
||||||
|
$css={css`
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: var(--c--globals--spacings--st);
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Icon variant="filled" $color="inherit" iconName="add_box" />
|
<Icon variant="filled" $color="inherit" iconName="add_box" />
|
||||||
</BoxButton>
|
</BoxButton>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export const useKeyboardActivation = (
|
|
||||||
keys: string[],
|
|
||||||
enabled: boolean,
|
|
||||||
action: () => void,
|
|
||||||
capture = false,
|
|
||||||
selector: string,
|
|
||||||
) => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (keys.includes(e.key)) {
|
|
||||||
e.preventDefault();
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const treeEl = document.querySelector<HTMLElement>(selector);
|
|
||||||
if (!treeEl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
treeEl.addEventListener('keydown', onKeyDown, capture);
|
|
||||||
return () => {
|
|
||||||
treeEl.removeEventListener('keydown', onKeyDown, capture);
|
|
||||||
};
|
|
||||||
}, [keys, enabled, action, capture, selector]);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user