(frontend) enable enter key to open documents and subdocuments

added keyboard support to open docs and subdocs using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-09-08 16:40:36 +02:00
parent c5f0142671
commit 942c90c29f
6 changed files with 190 additions and 49 deletions

View File

@@ -8,9 +8,14 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿(frontend) improve accessibility:
- #1354
### Fixed
- 🐛(backend) duplicate sub docs as root for reader user
- 🐛(backend) duplicate sub docs as root for reader users
## [3.7.0] - 2025-09-12

View File

@@ -252,6 +252,46 @@ test.describe('Doc Tree', () => {
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
page,
browserName,
}) => {
// Create a parent document
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-nav',
browserName,
1,
);
await verifyDocName(page, docParent);
// Create a sub-document
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-keyboard-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
// Test keyboard navigation on root document
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus on the root item and press Enter
await rootItem.focus();
await expect(rootItem).toBeFocused();
await page.keyboard.press('Enter');
// Verify we navigated to the root document
await verifyDocName(page, docParent);
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -49,6 +49,8 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
aria-label={`${t('Open document {{title}}', { title: doc.title || untitledDocument })}`}
>
<Box
$direction="row"
@@ -59,6 +61,7 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
@@ -96,6 +99,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}

View File

@@ -5,9 +5,10 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -20,6 +21,7 @@ import { useResponsiveStore } from '@/stores';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
const ItemTextCss = css`
overflow: hidden;
@@ -38,7 +40,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -46,6 +52,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const handleActivate = () => {
treeContext?.treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
};
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -76,62 +87,75 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter'],
isActive && !menuOpen,
handleActivate,
true,
'.c__tree-view',
);
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
background-color: ${actionsOpen
background-color: ${menuOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
display: ${menuOpen || !isDesktop ? 'flex' : 'none'};
position: absolute;
right: 0;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
}
.c__tree-view--node.isSelected {
.light-doc-item-actions {
background: var(--c--theme--colors--greyscale-100);
}
}
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
background: var(--c--theme--colors--greyscale-100);
}
}
.row.preview & {
background-color: inherit;
}
`}
>
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
onClick={(e) => {
e.stopPropagation();
handleActivate();
}}
$width="100%"
$direction="row"
$gap={spacingsTokens['xs']}
role="button"
tabIndex={0}
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
$css={css`
text-align: left;
`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -157,23 +181,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
iconName="group"
$size="16px"
$variation="400"
aria-hidden="true"
/>
)}
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={actionsOpen}
onOpenChange={setActionsOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</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>
</Box>

View File

@@ -7,6 +7,7 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -26,11 +27,16 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const rootIsSelected =
!!treeContext?.root?.id &&
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
@@ -39,9 +45,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { mutate: moveDoc } = useMoveDoc();
const { data: tree, isFetching } = useDocTree(
{
docId: currentDoc.id,
},
{ docId: currentDoc.id },
{
enabled: !treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -56,7 +60,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
});
treeContext?.treeData.handleMove(result);
};
/**
* This function resets the tree states.
*/
@@ -64,11 +67,39 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
const selectRoot = useCallback(() => {
if (treeContext?.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
}
}, [treeContext]);
const navigateToRoot = useCallback(() => {
const id = treeContext?.root?.id;
if (id) {
router.push(`/docs/${id}`);
}
}, [router, treeContext?.root?.id]);
const handleRootFocus = useCallback(() => {
selectRoot();
}, [selectRoot]);
// activate root document with enter or space
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRoot();
navigateToRoot();
}
},
[selectRoot, navigateToRoot],
);
/**
* This effect is used to reset the tree when a new document
* that is not part of the current tree is loaded.
@@ -77,7 +108,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree();
@@ -92,7 +122,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return () => {
resetStateTree();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -144,14 +173,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return null;
}
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return (
<Box
ref={setTreeRoot}
data-testid="doc-tree"
$height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css`
.c__tree-view--container {
z-index: 1;
@@ -171,6 +199,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
>
<Box
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document')}: ${treeContext.root?.title || t('Untitled document')}`}
aria-selected={rootIsSelected}
tabIndex={0}
onFocus={handleRootFocus}
onKeyDown={handleRootKeyDown}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
@@ -191,7 +225,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1;
}
}
&:hover {
&:hover,
&:focus-within {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -211,6 +246,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
tabIndex={-1} // avoid double tabstop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />

View File

@@ -0,0 +1,29 @@
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]);
};