Skip to content

Commit ad355ec

Browse files
committed
✨(frontend) keyboard support in sub-documents with f2 options access
adds f2 shortcut to open options menu in sub-documents Signed-off-by: Cyril <[email protected]>
1 parent 7475b7c commit ad355ec

File tree

5 files changed

+191
-22
lines changed

5 files changed

+191
-22
lines changed

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

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { useLeftPanelStore } from '@/features/left-panel';
2121
import { useResponsiveStore } from '@/stores';
2222

23+
import { useActionableMode } from '../hooks/useActionableMode';
2324
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
2425

2526
import SubPageIcon from './../assets/sub-page-logo.svg';
@@ -106,6 +107,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
106107
const isSelected = isSelectedNow;
107108
const ariaLabel = docTitle;
108109
const isDisabled = !!doc.deleted_at;
110+
const { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen);
109111

110112
return (
111113
<Box
@@ -117,6 +119,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
117119
aria-selected={isSelected}
118120
aria-expanded={hasChildren ? isExpanded : undefined}
119121
aria-disabled={isDisabled}
122+
onKeyDownCapture={onKeyDownCapture}
120123
$css={css`
121124
background-color: var(--c--globals--colors--gray-000);
122125
.light-doc-item-actions {
@@ -127,6 +130,13 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
127130
outline: none !important;
128131
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
129132
border-radius: var(--c--globals--spacings--st);
133+
.light-doc-item-actions {
134+
display: flex;
135+
}
136+
}
137+
/* Retirer le focus visuel du tree item quand le focus est sur les actions */
138+
&:has(.light-doc-item-actions *:focus) .c__tree-view--node.isFocused {
139+
box-shadow: none !important;
130140
}
131141
&:hover {
132142
background-color: var(
@@ -137,6 +147,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
137147
display: flex;
138148
}
139149
}
150+
&:focus-within {
151+
.light-doc-item-actions {
152+
display: flex;
153+
}
154+
}
140155
.row.preview & {
141156
background-color: inherit;
142157
}
@@ -153,6 +168,27 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
153168
docId={doc.id}
154169
title={doc.title}
155170
/>
171+
<Box
172+
$direction="row"
173+
$align="center"
174+
className="light-doc-item-actions actions"
175+
role="toolbar"
176+
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
177+
$css={css`
178+
margin-left: auto;
179+
order: 2;
180+
`}
181+
>
182+
<DocTreeItemActions
183+
doc={doc}
184+
isOpen={menuOpen}
185+
onOpenChange={setMenuOpen}
186+
parentId={node.data.parentKey}
187+
onCreateSuccess={afterCreate}
188+
actionsRef={actionsRef}
189+
onKeyDownCapture={onKeyDownCapture}
190+
/>
191+
</Box>
156192
<BoxButton
157193
onClick={(e) => {
158194
e.stopPropagation();
@@ -168,6 +204,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
168204
$css={css`
169205
text-align: left;
170206
min-width: 0;
207+
order: 1;
171208
`}
172209
>
173210
<Box
@@ -196,21 +233,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
196233
)}
197234
</Box>
198235
</BoxButton>
199-
<Box
200-
$direction="row"
201-
$align="center"
202-
className="light-doc-item-actions"
203-
role="toolbar"
204-
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
205-
>
206-
<DocTreeItemActions
207-
doc={doc}
208-
isOpen={menuOpen}
209-
onOpenChange={setMenuOpen}
210-
parentId={node.data.parentKey}
211-
onCreateSuccess={afterCreate}
212-
/>
213-
</Box>
214236
</TreeViewItem>
215237
</Box>
216238
);

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {
22
DropdownMenu,
33
DropdownMenuOption,
4+
useArrowRoving,
45
useTreeContext,
56
} from '@gouvfr-lasuite/ui-kit';
67
import { useModal } from '@openfun/cunningham-react';
78
import { useRouter } from 'next/router';
9+
import { useRef } from 'react';
810
import { useTranslation } from 'react-i18next';
911
import { css } from 'styled-components';
1012

@@ -30,6 +32,8 @@ type DocTreeItemActionsProps = {
3032
onCreateSuccess?: (newDoc: Doc) => void;
3133
onOpenChange?: (isOpen: boolean) => void;
3234
parentId?: string | null;
35+
actionsRef?: React.RefObject<HTMLDivElement | null>;
36+
onKeyDownCapture?: (e: React.KeyboardEvent) => void;
3337
};
3438

3539
export const DocTreeItemActions = ({
@@ -39,14 +43,21 @@ export const DocTreeItemActions = ({
3943
onCreateSuccess,
4044
onOpenChange,
4145
parentId,
46+
actionsRef,
47+
onKeyDownCapture,
4248
}: DocTreeItemActionsProps) => {
49+
const internalActionsRef = useRef<HTMLDivElement | null>(null);
50+
const targetActionsRef = actionsRef ?? internalActionsRef;
4351
const router = useRouter();
4452
const { t } = useTranslation();
4553
const deleteModal = useModal();
4654
const copyLink = useCopyDocLink(doc.id);
4755
const { mutate: detachDoc } = useDetachDoc();
4856
const treeContext = useTreeContext<Doc | null>();
4957

58+
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
59+
useArrowRoving(targetActionsRef.current);
60+
5061
const { mutate: duplicateDoc } = useDuplicateDoc({
5162
onSuccess: (duplicatedDoc) => {
5263
// Reset the tree context root will reset the full tree view.
@@ -160,30 +171,53 @@ export const DocTreeItemActions = ({
160171
};
161172

162173
return (
163-
<Box className="doc-tree-root-item-actions">
174+
<Box className="doc-tree-root-item-actions actions">
164175
<Box
176+
ref={targetActionsRef}
177+
onKeyDownCapture={onKeyDownCapture}
165178
$direction="row"
166179
$align="center"
167180
className="--docs--doc-tree-item-actions"
168181
$gap="4px"
182+
tabIndex={-1}
169183
>
170184
<DropdownMenu
171185
options={options}
172186
isOpen={isOpen}
173187
onOpenChange={onOpenChange}
174188
>
175-
<Icon
189+
<Box
190+
as="button"
191+
type="button"
176192
onClick={(e) => {
177193
e.stopPropagation();
178194
e.preventDefault();
179195
onOpenChange?.(!isOpen);
180196
}}
181-
iconName="more_horiz"
182-
variant="filled"
183-
$theme="brand"
184-
$variation="secondary"
185197
aria-label={t('More options')}
186-
/>
198+
$css={css`
199+
background: transparent;
200+
border: none;
201+
padding: 0;
202+
cursor: pointer;
203+
display: flex;
204+
align-items: center;
205+
justify-content: center;
206+
207+
&:focus-visible {
208+
outline: 2px solid var(--c--globals--colors--brand-500) !important;
209+
outline-offset: -2px;
210+
border-radius: var(--c--globals--spacings--st);
211+
}
212+
`}
213+
>
214+
<Icon
215+
iconName="more_horiz"
216+
variant="filled"
217+
$theme="brand"
218+
$variation="secondary"
219+
/>
220+
</Box>
187221
</DropdownMenu>
188222
{doc.abilities.children_create && (
189223
<BoxButton
@@ -199,6 +233,13 @@ export const DocTreeItemActions = ({
199233
$variation="secondary"
200234
aria-label={t('Add a sub page')}
201235
data-testid="doc-tree-item-actions-add-child"
236+
$css={css`
237+
&:focus-visible {
238+
outline: 2px solid var(--c--globals--colors--brand-500) !important;
239+
outline-offset: -2px;
240+
border-radius: var(--c--globals--spacings--st);
241+
}
242+
`}
202243
>
203244
<Icon variant="filled" $color="inherit" iconName="add_box" />
204245
</BoxButton>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const SELECTORS = {
2+
MODAL:
3+
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
4+
ACTIONS_TOOLBAR: '.actions, .light-doc-item-actions',
5+
INTERACTIVE_ELEMENTS:
6+
'button, a[href], input, textarea, select, [role="menuitem"], [role="button"]',
7+
} as const;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { SELECTORS } from '../dom-selectors';
4+
5+
export type ActionableNodeLike = {
6+
isFocused?: boolean;
7+
focus?: () => void;
8+
};
9+
10+
export const useActionableMode = (
11+
node: ActionableNodeLike,
12+
isMenuOpen?: boolean,
13+
) => {
14+
const actionsRef = useRef<HTMLDivElement>(null);
15+
16+
useEffect(() => {
17+
// Handles F2 to focus the first actionable element in the actions area, except when a modal is open
18+
const toActions = (e: KeyboardEvent) => {
19+
if (e.key !== 'F2' || document.querySelector(SELECTORS.MODAL)) {
20+
return;
21+
}
22+
23+
// Only react if the node is currently focused
24+
if (!node?.isFocused) {
25+
return;
26+
}
27+
28+
const focusables = getFocusableElements();
29+
if (focusables.length === 0) {
30+
return;
31+
}
32+
33+
e.preventDefault();
34+
e.stopPropagation();
35+
36+
const first = focusables[0];
37+
// Ensure the element is focusable even if it's a <div> etc.
38+
if (
39+
first instanceof HTMLElement &&
40+
!first.hasAttribute('tabindex') &&
41+
first.tabIndex === -1
42+
) {
43+
first.setAttribute('tabindex', '-1');
44+
}
45+
46+
// TreeView may reclaim focus after this event cycle; setTimeout guarantees focus happens after
47+
setTimeout(() => {
48+
first.focus();
49+
}, 0);
50+
};
51+
52+
document.addEventListener('keydown', toActions, true);
53+
return () => document.removeEventListener('keydown', toActions, true);
54+
// node is a dependency, as it's checked for focus state
55+
}, [node]);
56+
57+
// Returns all focusable action elements (buttons or role="button") inside the actionsRef
58+
const getFocusableElements = () => {
59+
if (!actionsRef.current) {
60+
return [];
61+
}
62+
return Array.from(
63+
actionsRef.current.querySelectorAll<HTMLElement>(
64+
'button, [role="button"]',
65+
),
66+
);
67+
};
68+
69+
const onKeyDownCapture = (e: React.KeyboardEvent) => {
70+
// Do nothing if the menu is open or a modal is displayed
71+
if (isMenuOpen || document.querySelector(SELECTORS.MODAL)) {
72+
return;
73+
}
74+
75+
// Escape: return focus to the tree node
76+
if (e.key === 'Escape') {
77+
e.stopPropagation();
78+
node?.focus?.();
79+
}
80+
};
81+
82+
return { actionsRef, onKeyDownCapture };
83+
};

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { useEffect } from 'react';
22

3+
import { SELECTORS } from '../dom-selectors';
4+
5+
/**
6+
* Custom hook to activate an action with specific keyboard keys,
7+
* unless focus is inside actions toolbar or on an interactive element.
8+
*/
39
export const useKeyboardActivation = (
410
keys: string[],
511
enabled: boolean,
@@ -12,6 +18,16 @@ export const useKeyboardActivation = (
1218
return;
1319
}
1420
const onKeyDown = (e: KeyboardEvent) => {
21+
// Ignore if the focus is inside the actions toolbar or on an interactive element
22+
const target = e.target as HTMLElement | null;
23+
if (target) {
24+
const isInActions = target.closest(SELECTORS.ACTIONS_TOOLBAR);
25+
const isInteractive = target.closest(SELECTORS.INTERACTIVE_ELEMENTS);
26+
if (isInActions || isInteractive) {
27+
return;
28+
}
29+
}
30+
1531
if (keys.includes(e.key)) {
1632
e.preventDefault();
1733
action();

0 commit comments

Comments
 (0)