Skip to content

Commit 2907b3e

Browse files
committed
fixup! ✨(frontend) make components accessible to screen readers
1 parent 3528803 commit 2907b3e

File tree

5 files changed

+155
-41
lines changed

5 files changed

+155
-41
lines changed

src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DateTime } from 'luxon';
2+
import { useRouter } from 'next/navigation';
23
import { useTranslation } from 'react-i18next';
34
import { css } from 'styled-components';
45

@@ -26,17 +27,28 @@ type SimpleDocItemProps = {
2627
doc: Doc;
2728
isPinned?: boolean;
2829
showAccesses?: boolean;
30+
onActivate?: () => void;
2931
};
3032

3133
export const SimpleDocItem = ({
3234
doc,
3335
isPinned = false,
3436
showAccesses = false,
37+
onActivate,
3538
}: SimpleDocItemProps) => {
3639
const { t } = useTranslation();
3740
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
3841
const { isDesktop } = useResponsiveStore();
3942
const { untitledDocument } = useTrans();
43+
const router = useRouter();
44+
45+
const handleActivate = () => {
46+
if (onActivate) {
47+
onActivate();
48+
} else {
49+
router.push(`/docs/${doc.id}`);
50+
}
51+
};
4052

4153
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
4254
doc.title || untitledDocument,
@@ -50,6 +62,8 @@ export const SimpleDocItem = ({
5062
$width="100%"
5163
className="--docs--simple-doc-item"
5264
role="presentation"
65+
onClick={handleActivate}
66+
aria-label={`${t('Open document')} ${doc.title || untitledDocument}`}
5367
>
5468
<Box
5569
$direction="row"
@@ -90,7 +104,6 @@ export const SimpleDocItem = ({
90104
$variation="1000"
91105
$weight="500"
92106
$css={ItemTextCss}
93-
aria-describedby="doc-title"
94107
>
95108
{displayTitle}
96109
</Text>

src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
110110
const isExpanded = node.isOpen;
111111
const isSelected = isSelectedNow;
112112

113-
const ariaLabel = `${docTitle}${hasChildren ? `, ${isExpanded ? t('expanded') : t('collapsed')}` : ''}${isSelected ? `, ${t('selected')}` : ''}`;
113+
const ariaLabel = docTitle;
114114

115115
return (
116116
<Box
@@ -216,7 +216,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
216216
$direction="row"
217217
$align="center"
218218
className="light-doc-item-actions"
219-
role="group"
219+
role="toolbar"
220220
aria-label={`${t('Actions for')} ${docTitle}`}
221221
>
222222
<DocTreeItemActions

src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
useResponsive,
66
useTreeContext,
77
} from '@gouvfr-lasuite/ui-kit';
8-
import { useRouter } from 'next/navigation';
98
import { useCallback, useEffect, useState } from 'react';
109
import { useTranslation } from 'react-i18next';
1110
import { css } from 'styled-components';
@@ -16,6 +15,7 @@ import { Doc, SimpleDocItem } from '@/docs/doc-management';
1615

1716
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
1817
import { useMoveDoc } from '../api/useMove';
18+
import { useRootTreeItem } from '../hooks/useRootTreeItem';
1919
import { findIndexInTree } from '../utils';
2020

2121
import { DocSubPageItem } from './DocSubPageItem';
@@ -27,9 +27,7 @@ type DocTreeProps = {
2727

2828
export const DocTree = ({ currentDoc }: DocTreeProps) => {
2929
const { spacingsTokens } = useCunninghamTheme();
30-
const [rootActionsOpen, setRootActionsOpen] = useState(false);
3130
const treeContext = useTreeContext<Doc | null>();
32-
const router = useRouter();
3331
const { isDesktop } = useResponsive();
3432
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
3533
const { t } = useTranslation();
@@ -39,11 +37,20 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
3937
);
4038

4139
const { mutate: moveDoc } = useMoveDoc();
42-
40+
const {
41+
rootIsSelected,
42+
rootActionsOpen,
43+
setRootActionsOpen,
44+
rootActionsRef,
45+
onRootToolbarKeys,
46+
handleRootFocus,
47+
handleRootKeyDown,
48+
handleRootClick,
49+
handleRootActivate,
50+
handleCreateSuccess,
51+
} = useRootTreeItem();
4352
const { data: tree, isFetching } = useDocTree(
44-
{
45-
docId: currentDoc.id,
46-
},
53+
{ docId: currentDoc.id },
4754
{
4855
enabled: !!!treeContext?.root?.id,
4956
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -58,15 +65,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
5865
});
5966
treeContext?.treeData.handleMove(result);
6067
};
61-
6268
/**
6369
* This function resets the tree states.
6470
*/
6571
const resetStateTree = useCallback(() => {
6672
if (!treeContext?.root?.id) {
6773
return;
6874
}
69-
7075
treeContext?.setRoot(null);
7176
setInitialOpenState(undefined);
7277
}, [treeContext]);
@@ -79,7 +84,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
7984
if (!treeContext?.root?.id) {
8085
return;
8186
}
82-
8387
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
8488
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
8589
resetStateTree();
@@ -94,7 +98,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
9498
return () => {
9599
resetStateTree();
96100
};
97-
98101
// eslint-disable-next-line react-hooks/exhaustive-deps
99102
}, []);
100103

@@ -142,13 +145,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
142145
}
143146
}, [currentDoc, treeContext]);
144147

148+
/**
149+
* This is the main return of the component.
150+
*/
145151
if (!treeContext || !treeContext.root) {
146152
return null;
147153
}
148154

149-
const rootIsSelected =
150-
treeContext.treeData.selectedNode?.id === treeContext.root.id;
151-
152155
return (
153156
<Box
154157
ref={setTreeRoot}
@@ -178,6 +181,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
178181
role="treeitem"
179182
aria-label={`${t('Root document')}: ${treeContext.root?.title || t('Untitled document')}`}
180183
aria-selected={rootIsSelected}
184+
tabIndex={0}
185+
onFocus={handleRootFocus}
186+
onKeyDown={handleRootKeyDown}
181187
$css={css`
182188
padding: ${spacingsTokens['2xs']};
183189
border-radius: 4px;
@@ -211,32 +217,24 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
211217
width: 100%;
212218
`}
213219
href={`/docs/${treeContext.root.id}`}
214-
onClick={(e) => {
215-
e.stopPropagation();
216-
e.preventDefault();
217-
treeContext.treeData.setSelectedNode(
218-
treeContext.root ?? undefined,
219-
);
220-
router.push(`/docs/${treeContext?.root?.id}`);
221-
}}
220+
onClick={handleRootClick}
222221
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
222+
tabIndex={-1} // évite le double tabstop
223223
>
224224
<Box $direction="row" $align="center" $width="100%">
225-
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
225+
<SimpleDocItem
226+
doc={treeContext.root}
227+
showAccesses={true}
228+
onActivate={handleRootActivate}
229+
/>
226230
<DocTreeItemActions
227231
doc={treeContext.root}
228-
onCreateSuccess={(createdDoc) => {
229-
const newDoc = {
230-
...createdDoc,
231-
children: [],
232-
childrenCount: 0,
233-
parentId: treeContext.root?.id ?? undefined,
234-
};
235-
treeContext?.treeData.addChild(null, newDoc);
236-
}}
232+
onCreateSuccess={handleCreateSuccess}
237233
isOpen={rootActionsOpen}
238234
isRoot={true}
239235
onOpenChange={setRootActionsOpen}
236+
actionsRef={rootActionsRef}
237+
onKeyDownCapture={onRootToolbarKeys}
240238
/>
241239
</Box>
242240
</StyledLink>

src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { TreeDataItem } from '@gouvfr-lasuite/ui-kit';
21
import { useEffect, useRef } from 'react';
3-
import type { NodeRendererProps } from 'react-arborist';
42

53
import { SELECTORS } from '../dom-selectors';
64

7-
type FocusableNode<T> = NodeRendererProps<TreeDataItem<T>>['node'] & {
5+
export type ActionableNodeLike = {
86
isFocused?: boolean;
97
focus?: () => void;
108
};
@@ -14,8 +12,8 @@ type FocusableNode<T> = NodeRendererProps<TreeDataItem<T>>['node'] & {
1412
*
1513
* Disables navigation when dropdown menu is open to prevent conflicts.
1614
*/
17-
export const useActionableMode = <T>(
18-
node: FocusableNode<T>,
15+
export const useActionableMode = (
16+
node: ActionableNodeLike,
1917
isMenuOpen?: boolean,
2018
) => {
2119
const actionsRef = useRef<HTMLDivElement>(null);
@@ -32,7 +30,7 @@ export const useActionableMode = <T>(
3230
return;
3331
}
3432

35-
if (e.key === 'F2' || e.key === 'Enter') {
33+
if (e.key === 'F2') {
3634
const isAlreadyInActions = actionsRef.current?.contains(
3735
document.activeElement,
3836
);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// useRootTreeItem.ts
2+
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
3+
import { useRouter } from 'next/navigation';
4+
import { RefObject, useCallback, useState } from 'react';
5+
6+
import type { Doc } from '@/docs/doc-management';
7+
8+
import { useActionableMode } from '../hooks/useActionableMode';
9+
import type { ActionableNodeLike } from '../hooks/useActionableMode';
10+
11+
export function useRootTreeItem() {
12+
const treeContext = useTreeContext<Doc | null>();
13+
const router = useRouter();
14+
const [rootActionsOpen, setRootActionsOpen] = useState(false);
15+
16+
const rootIsSelected =
17+
!!treeContext?.root?.id &&
18+
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
19+
20+
/**
21+
* This is a fake node used to reuse the useActionableMode hook on the root.
22+
*/
23+
const fakeRootNode: ActionableNodeLike = {
24+
isFocused: rootIsSelected,
25+
focus: () => {
26+
const root = treeContext?.root;
27+
if (root) {
28+
treeContext?.treeData.setSelectedNode(root);
29+
}
30+
},
31+
};
32+
33+
const { actionsRef: rootActionsRef, onKeyDownCapture: onRootToolbarKeys } =
34+
useActionableMode(fakeRootNode, rootActionsOpen);
35+
36+
const selectRoot = useCallback(() => {
37+
if (treeContext?.root) {
38+
treeContext.treeData.setSelectedNode(treeContext.root);
39+
}
40+
}, [treeContext]);
41+
42+
const navigateToRoot = useCallback(() => {
43+
const id = treeContext?.root?.id;
44+
if (id) {
45+
router.push(`/docs/${id}`);
46+
}
47+
}, [router, treeContext?.root?.id]);
48+
49+
const handleRootFocus = useCallback(() => {
50+
selectRoot();
51+
}, [selectRoot]);
52+
53+
const handleRootKeyDown = useCallback(
54+
(e: React.KeyboardEvent) => {
55+
if (e.key === 'Enter' || e.key === ' ') {
56+
e.preventDefault();
57+
selectRoot();
58+
navigateToRoot();
59+
}
60+
},
61+
[selectRoot, navigateToRoot],
62+
);
63+
64+
const handleRootClick = useCallback(
65+
(e: React.MouseEvent) => {
66+
e.stopPropagation();
67+
e.preventDefault();
68+
selectRoot();
69+
navigateToRoot();
70+
},
71+
[selectRoot, navigateToRoot],
72+
);
73+
74+
const handleRootActivate = useCallback(() => {
75+
selectRoot();
76+
navigateToRoot();
77+
}, [selectRoot, navigateToRoot]);
78+
79+
const handleCreateSuccess = useCallback(
80+
(createdDoc: Doc) => {
81+
const newDoc = {
82+
...createdDoc,
83+
children: [],
84+
childrenCount: 0,
85+
parentId: treeContext?.root?.id ?? undefined,
86+
};
87+
treeContext?.treeData.addChild(null, newDoc);
88+
},
89+
[treeContext],
90+
);
91+
92+
return {
93+
rootIsSelected,
94+
rootActionsOpen,
95+
setRootActionsOpen,
96+
rootActionsRef: rootActionsRef as RefObject<HTMLDivElement>,
97+
onRootToolbarKeys,
98+
99+
handleRootFocus,
100+
handleRootKeyDown,
101+
handleRootClick,
102+
handleRootActivate,
103+
handleCreateSuccess,
104+
};
105+
}

0 commit comments

Comments
 (0)