(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] ## [Unreleased]
### Changed
- ♿(frontend) improve accessibility:
- #1354
### Fixed ### 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 ## [3.7.0] - 2025-09-12

View File

@@ -252,6 +252,46 @@ test.describe('Doc Tree', () => {
page.getByRole('menuitem', { name: 'Move to my docs' }), page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true'); ).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', () => { test.describe('Doc Tree: Inheritance', () => {

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
} 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, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, StyledLink } from '@/components'; import { Box, StyledLink } from '@/components';
@@ -26,11 +27,16 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => { export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const { isDesktop } = useResponsive(); const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null); 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>( const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined, undefined,
@@ -39,9 +45,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { mutate: moveDoc } = useMoveDoc(); const { mutate: moveDoc } = useMoveDoc();
const { data: tree, isFetching } = useDocTree( const { data: tree, isFetching } = useDocTree(
{ { docId: currentDoc.id },
docId: currentDoc.id,
},
{ {
enabled: !treeContext?.root?.id, enabled: !treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }], queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -56,7 +60,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}); });
treeContext?.treeData.handleMove(result); treeContext?.treeData.handleMove(result);
}; };
/** /**
* This function resets the tree states. * This function resets the tree states.
*/ */
@@ -64,11 +67,39 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) { if (!treeContext?.root?.id) {
return; return;
} }
treeContext?.setRoot(null); treeContext?.setRoot(null);
setInitialOpenState(undefined); setInitialOpenState(undefined);
}, [treeContext]); }, [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 * 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.
@@ -77,7 +108,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) { if (!treeContext?.root?.id) {
return; return;
} }
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id); const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) { if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree(); resetStateTree();
@@ -92,7 +122,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return () => { return () => {
resetStateTree(); resetStateTree();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -144,14 +173,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return null; return null;
} }
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return ( return (
<Box <Box
ref={setTreeRoot} ref={setTreeRoot}
data-testid="doc-tree" data-testid="doc-tree"
$height="100%" $height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css` $css={css`
.c__tree-view--container { .c__tree-view--container {
z-index: 1; z-index: 1;
@@ -171,6 +199,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
> >
<Box <Box
data-testid="doc-tree-root-item" 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` $css={css`
padding: ${spacingsTokens['2xs']}; padding: ${spacingsTokens['2xs']};
border-radius: 4px; border-radius: 4px;
@@ -191,7 +225,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1; opacity: 1;
} }
} }
&:hover { &:hover,
&:focus-within {
.doc-tree-root-item-actions { .doc-tree-root-item-actions {
opacity: 1; opacity: 1;
} }
@@ -211,6 +246,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
); );
router.push(`/docs/${treeContext?.root?.id}`); 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%"> <Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} /> <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]);
};