Skip to content

Commit dcc07fa

Browse files
committed
✨(frontend) add keyboard navigation for subdocs with focus activation
enter/space now trigger only on real focus add useTreeItemKeyboardActivate hook Signed-off-by: Cyril <[email protected]>
1 parent c94f0b2 commit dcc07fa

File tree

6 files changed

+370
-50
lines changed

6 files changed

+370
-50
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { TreeDataItem } from '@gouvfr-lasuite/ui-kit';
2+
import { useEffect, useRef } from 'react';
3+
import type { NodeRendererProps } from 'react-arborist';
4+
5+
type FocusableNode<T> = NodeRendererProps<TreeDataItem<T>>['node'] & {
6+
isFocused?: boolean;
7+
focus?: () => void;
8+
};
9+
10+
/**
11+
* Hook to manage keyboard navigation for actionable items in a tree view.
12+
*
13+
* Provides two modes:
14+
* 1. Activation: F2/Enter moves focus to first actionable element
15+
* 2. Navigation: Arrow keys navigate between actions, Escape returns to tree node
16+
*
17+
* Disables navigation when dropdown menu is open to prevent conflicts.
18+
*/
19+
export const useActionableMode = <T>(
20+
node: FocusableNode<T>,
21+
isMenuOpen?: boolean,
22+
) => {
23+
const actionsRef = useRef<HTMLDivElement>(null);
24+
25+
useEffect(() => {
26+
if (!node?.isFocused) {
27+
return;
28+
}
29+
30+
const toActions = (e: KeyboardEvent) => {
31+
if (e.key === 'F2' || e.key === 'Enter') {
32+
const isAlreadyInActions = actionsRef.current?.contains(
33+
document.activeElement,
34+
);
35+
36+
if (isAlreadyInActions) {
37+
return;
38+
}
39+
40+
e.preventDefault();
41+
42+
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
43+
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
44+
);
45+
46+
const first = focusables?.[0];
47+
48+
first?.focus();
49+
}
50+
};
51+
52+
document.addEventListener('keydown', toActions, true);
53+
54+
return () => {
55+
document.removeEventListener('keydown', toActions, true);
56+
};
57+
}, [node?.isFocused]);
58+
59+
const onKeyDownCapture = (e: React.KeyboardEvent) => {
60+
if (isMenuOpen) {
61+
return;
62+
}
63+
64+
if (e.key === 'Escape') {
65+
e.stopPropagation();
66+
node?.focus?.();
67+
}
68+
69+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
70+
e.preventDefault();
71+
e.stopPropagation();
72+
73+
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
74+
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
75+
);
76+
77+
if (!focusables || focusables.length === 0) {
78+
return;
79+
}
80+
81+
const currentIndex = Array.from(focusables).findIndex(
82+
(el) => el === document.activeElement,
83+
);
84+
85+
let nextIndex: number;
86+
if (e.key === 'ArrowLeft') {
87+
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusables.length - 1;
88+
} else {
89+
nextIndex = currentIndex < focusables.length - 1 ? currentIndex + 1 : 0;
90+
}
91+
92+
focusables[nextIndex]?.focus();
93+
}
94+
};
95+
96+
return { actionsRef, onKeyDownCapture };
97+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useCallback } from 'react';
2+
3+
interface UseDocTreeItemHandlersProps {
4+
isOpen?: boolean;
5+
onOpenChange?: (isOpen: boolean) => void;
6+
createChildDoc: (params: { parentId: string }) => void;
7+
docId: string;
8+
}
9+
10+
export const useDocTreeItemHandlers = ({
11+
isOpen,
12+
onOpenChange,
13+
createChildDoc,
14+
docId,
15+
}: UseDocTreeItemHandlersProps) => {
16+
const preventDefaultAndStopPropagation = useCallback(
17+
(e: React.MouseEvent | React.KeyboardEvent) => {
18+
e.stopPropagation();
19+
e.preventDefault();
20+
},
21+
[],
22+
);
23+
24+
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
25+
return e.key === 'Enter' || e.key === ' ';
26+
}, []);
27+
28+
const handleMoreOptionsClick = useCallback(
29+
(e: React.MouseEvent) => {
30+
preventDefaultAndStopPropagation(e);
31+
onOpenChange?.(!isOpen);
32+
},
33+
[isOpen, onOpenChange, preventDefaultAndStopPropagation],
34+
);
35+
36+
const handleMoreOptionsKeyDown = useCallback(
37+
(e: React.KeyboardEvent) => {
38+
if (isValidKeyEvent(e)) {
39+
preventDefaultAndStopPropagation(e);
40+
onOpenChange?.(!isOpen);
41+
}
42+
},
43+
[isOpen, onOpenChange, preventDefaultAndStopPropagation, isValidKeyEvent],
44+
);
45+
46+
const handleAddChildClick = useCallback(
47+
(e: React.MouseEvent) => {
48+
preventDefaultAndStopPropagation(e);
49+
void createChildDoc({
50+
parentId: docId,
51+
});
52+
},
53+
[createChildDoc, docId, preventDefaultAndStopPropagation],
54+
);
55+
56+
const handleAddChildKeyDown = useCallback(
57+
(e: React.KeyboardEvent) => {
58+
if (isValidKeyEvent(e)) {
59+
preventDefaultAndStopPropagation(e);
60+
void createChildDoc({
61+
parentId: docId,
62+
});
63+
}
64+
},
65+
[createChildDoc, docId, preventDefaultAndStopPropagation, isValidKeyEvent],
66+
);
67+
68+
return {
69+
handleMoreOptionsClick,
70+
handleMoreOptionsKeyDown,
71+
handleAddChildClick,
72+
handleAddChildKeyDown,
73+
};
74+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useEffect } from 'react';
2+
3+
interface UseDropdownFocusManagementProps {
4+
isOpen: boolean;
5+
docId: string;
6+
actionsRef?: React.RefObject<HTMLDivElement>;
7+
}
8+
9+
export const useDropdownFocusManagement = ({
10+
isOpen,
11+
docId,
12+
actionsRef,
13+
}: UseDropdownFocusManagementProps) => {
14+
// Focus management for dropdown menu opening
15+
useEffect(() => {
16+
if (!isOpen) {
17+
return;
18+
}
19+
20+
const timer = setTimeout(() => {
21+
// Try to find menu in actions container first
22+
const menuElement = actionsRef?.current
23+
?.closest('.--docs--doc-tree-item-actions')
24+
?.querySelector('[role="menu"]');
25+
26+
if (menuElement) {
27+
const firstMenuItem = menuElement.querySelector<HTMLElement>(
28+
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
29+
);
30+
if (firstMenuItem) {
31+
firstMenuItem.focus();
32+
return;
33+
}
34+
}
35+
36+
// Fallback: find any menu in document
37+
const allMenus = document.querySelectorAll('[role="menu"]');
38+
const lastMenu = allMenus[allMenus.length - 1];
39+
if (lastMenu) {
40+
const firstMenuItem = lastMenu.querySelector<HTMLElement>(
41+
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
42+
);
43+
if (firstMenuItem) {
44+
firstMenuItem.focus();
45+
}
46+
}
47+
}, 100);
48+
49+
return () => clearTimeout(timer);
50+
}, [isOpen, actionsRef]);
51+
52+
// Focus management for returning to sub-document when menu closes
53+
useEffect(() => {
54+
if (isOpen) {
55+
return;
56+
}
57+
58+
const timer = setTimeout(() => {
59+
// Try to find sub-document by closest ancestor
60+
let subPageItem = actionsRef?.current?.closest('.--docs-sub-page-item');
61+
62+
// If not found, try to find by data-testid
63+
if (!subPageItem) {
64+
const testIdElement = document.querySelector(
65+
`[data-testid="doc-sub-page-item-${docId}"]`,
66+
);
67+
subPageItem =
68+
testIdElement?.closest('.--docs-sub-page-item') ||
69+
testIdElement?.parentElement?.closest('.--docs-sub-page-item');
70+
}
71+
72+
// Focus the sub-document if found
73+
if (subPageItem) {
74+
const focusableElement = subPageItem.querySelector<HTMLElement>(
75+
'[data-testid^="doc-sub-page-item-"]',
76+
);
77+
78+
if (focusableElement) {
79+
focusableElement.focus();
80+
} else {
81+
(subPageItem as HTMLElement).focus();
82+
}
83+
return;
84+
}
85+
86+
// Fallback: focus actions container
87+
actionsRef?.current?.focus();
88+
}, 100);
89+
90+
return () => clearTimeout(timer);
91+
}, [isOpen, actionsRef, docId]);
92+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEffect } from 'react';
2+
3+
/**
4+
* While the node has keyboard focus, run `activate()` on Enter / Space.
5+
* Gives tree-items the same "open on Enter" behaviour that clicks already have.
6+
*/
7+
export const useTreeItemKeyboardActivate = (
8+
focused: boolean,
9+
activate: () => void,
10+
) => {
11+
useEffect(() => {
12+
if (!focused) {
13+
return;
14+
}
15+
16+
const onKeyDown = (e: KeyboardEvent) => {
17+
if (e.key === 'Enter' || e.key === ' ') {
18+
e.preventDefault();
19+
activate();
20+
}
21+
};
22+
23+
document.addEventListener('keydown', onKeyDown, true);
24+
return () => document.removeEventListener('keydown', onKeyDown, true);
25+
}, [focused, activate]);
26+
};

0 commit comments

Comments
 (0)