(frontend) add keyboard navigation for subdocs with focus activation

enter/space now trigger only on real focus add useTreeItemKeyboardActivate hook

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-08-05 09:53:44 +02:00
parent d84a9cb24a
commit a5e22fddf9
6 changed files with 370 additions and 50 deletions

View File

@@ -0,0 +1,97 @@
import { TreeDataItem } from '@gouvfr-lasuite/ui-kit';
import { useEffect, useRef } from 'react';
import type { NodeRendererProps } from 'react-arborist';
type FocusableNode<T> = NodeRendererProps<TreeDataItem<T>>['node'] & {
isFocused?: boolean;
focus?: () => void;
};
/**
* Hook to manage keyboard navigation for actionable items in a tree view.
*
* Provides two modes:
* 1. Activation: F2/Enter moves focus to first actionable element
* 2. Navigation: Arrow keys navigate between actions, Escape returns to tree node
*
* Disables navigation when dropdown menu is open to prevent conflicts.
*/
export const useActionableMode = <T>(
node: FocusableNode<T>,
isMenuOpen?: boolean,
) => {
const actionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!node?.isFocused) {
return;
}
const toActions = (e: KeyboardEvent) => {
if (e.key === 'F2' || e.key === 'Enter') {
const isAlreadyInActions = actionsRef.current?.contains(
document.activeElement,
);
if (isAlreadyInActions) {
return;
}
e.preventDefault();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
);
const first = focusables?.[0];
first?.focus();
}
};
document.addEventListener('keydown', toActions, true);
return () => {
document.removeEventListener('keydown', toActions, true);
};
}, [node?.isFocused]);
const onKeyDownCapture = (e: React.KeyboardEvent) => {
if (isMenuOpen) {
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
node?.focus?.();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
const focusables = actionsRef.current?.querySelectorAll<HTMLElement>(
'button, [role="button"], a[href], input, [tabindex]:not([tabindex="-1"])',
);
if (!focusables || focusables.length === 0) {
return;
}
const currentIndex = Array.from(focusables).findIndex(
(el) => el === document.activeElement,
);
let nextIndex: number;
if (e.key === 'ArrowLeft') {
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusables.length - 1;
} else {
nextIndex = currentIndex < focusables.length - 1 ? currentIndex + 1 : 0;
}
focusables[nextIndex]?.focus();
}
};
return { actionsRef, onKeyDownCapture };
};

View File

@@ -0,0 +1,74 @@
import { useCallback } from 'react';
interface UseDocTreeItemHandlersProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
createChildDoc: (params: { parentId: string }) => void;
docId: string;
}
export const useDocTreeItemHandlers = ({
isOpen,
onOpenChange,
createChildDoc,
docId,
}: UseDocTreeItemHandlersProps) => {
const preventDefaultAndStopPropagation = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
},
[],
);
const isValidKeyEvent = useCallback((e: React.KeyboardEvent) => {
return e.key === 'Enter' || e.key === ' ';
}, []);
const handleMoreOptionsClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation],
);
const handleMoreOptionsKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
onOpenChange?.(!isOpen);
}
},
[isOpen, onOpenChange, preventDefaultAndStopPropagation, isValidKeyEvent],
);
const handleAddChildClick = useCallback(
(e: React.MouseEvent) => {
preventDefaultAndStopPropagation(e);
void createChildDoc({
parentId: docId,
});
},
[createChildDoc, docId, preventDefaultAndStopPropagation],
);
const handleAddChildKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isValidKeyEvent(e)) {
preventDefaultAndStopPropagation(e);
void createChildDoc({
parentId: docId,
});
}
},
[createChildDoc, docId, preventDefaultAndStopPropagation, isValidKeyEvent],
);
return {
handleMoreOptionsClick,
handleMoreOptionsKeyDown,
handleAddChildClick,
handleAddChildKeyDown,
};
};

View File

@@ -0,0 +1,92 @@
import { useEffect } from 'react';
interface UseDropdownFocusManagementProps {
isOpen: boolean;
docId: string;
actionsRef?: React.RefObject<HTMLDivElement>;
}
export const useDropdownFocusManagement = ({
isOpen,
docId,
actionsRef,
}: UseDropdownFocusManagementProps) => {
// Focus management for dropdown menu opening
useEffect(() => {
if (!isOpen) {
return;
}
const timer = setTimeout(() => {
// Try to find menu in actions container first
const menuElement = actionsRef?.current
?.closest('.--docs--doc-tree-item-actions')
?.querySelector('[role="menu"]');
if (menuElement) {
const firstMenuItem = menuElement.querySelector<HTMLElement>(
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
);
if (firstMenuItem) {
firstMenuItem.focus();
return;
}
}
// Fallback: find any menu in document
const allMenus = document.querySelectorAll('[role="menu"]');
const lastMenu = allMenus[allMenus.length - 1];
if (lastMenu) {
const firstMenuItem = lastMenu.querySelector<HTMLElement>(
'[role="menuitem"], button, [tabindex]:not([tabindex="-1"])',
);
if (firstMenuItem) {
firstMenuItem.focus();
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef]);
// Focus management for returning to sub-document when menu closes
useEffect(() => {
if (isOpen) {
return;
}
const timer = setTimeout(() => {
// Try to find sub-document by closest ancestor
let subPageItem = actionsRef?.current?.closest('.--docs-sub-page-item');
// If not found, try to find by data-testid
if (!subPageItem) {
const testIdElement = document.querySelector(
`[data-testid="doc-sub-page-item-${docId}"]`,
);
subPageItem =
testIdElement?.closest('.--docs-sub-page-item') ||
testIdElement?.parentElement?.closest('.--docs-sub-page-item');
}
// Focus the sub-document if found
if (subPageItem) {
const focusableElement = subPageItem.querySelector<HTMLElement>(
'[data-testid^="doc-sub-page-item-"]',
);
if (focusableElement) {
focusableElement.focus();
} else {
(subPageItem as HTMLElement).focus();
}
return;
}
// Fallback: focus actions container
actionsRef?.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, [isOpen, actionsRef, docId]);
};

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* While the node has keyboard focus, run `activate()` on Enter / Space.
* Gives tree-items the same "open on Enter" behaviour that clicks already have.
*/
export const useTreeItemKeyboardActivate = (
focused: boolean,
activate: () => void,
) => {
useEffect(() => {
if (!focused) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [focused, activate]);
};

View File

@@ -7,7 +7,9 @@ import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useActionableMode } from '@/components/dropdown-menu/hook/useActionableMode';
import { useTreeItemKeyboardActivate } from '@/components/dropdown-menu/hook/useTreeItemKeyboardActivate';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -38,7 +40,10 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const isActive = node.isFocused || menuOpen;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -46,6 +51,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 { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -76,23 +86,30 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useTreeItemKeyboardActivate(isActive, handleActivate);
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
$css={css`
background-color: ${actionsOpen
background-color: ${isActive
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
display: flex;
opacity: ${isActive || !isDesktop ? 1 : 0};
visibility: ${isActive || !isDesktop ? 'visible' : 'hidden'};
position: absolute;
right: 0;
top: 0;
height: 100%;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
z-index: 10;
}
&:focus-within .light-doc-item-actions {
@@ -122,22 +139,19 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
`}
>
<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
as="button"
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}`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -163,25 +177,28 @@ 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>
</Box>
</BoxButton>
</TreeViewItem>
<Box
ref={actionsRef}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
);
};

View File

@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { useDocTreeItemHandlers } from '@/components/dropdown-menu/hook/useDocTreeItemHandlers';
import { useDropdownFocusManagement } from '@/components/dropdown-menu/hook/useDropdownFocusManagement';
import {
Doc,
ModalRemoveDoc,
@@ -28,6 +30,8 @@ type DocTreeItemActionsProps = {
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement>;
onKeyDownCapture?: (e: React.KeyboardEvent) => void;
};
export const DocTreeItemActions = ({
@@ -37,6 +41,8 @@ export const DocTreeItemActions = ({
onCreateSuccess,
onOpenChange,
parentId,
actionsRef,
onKeyDownCapture,
}: DocTreeItemActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
@@ -143,9 +149,30 @@ export const DocTreeItemActions = ({
}
};
const {
handleMoreOptionsClick,
handleMoreOptionsKeyDown,
handleAddChildClick,
handleAddChildKeyDown,
} = useDocTreeItemHandlers({
isOpen,
onOpenChange,
createChildDoc,
docId: doc.id,
});
useDropdownFocusManagement({
isOpen: isOpen || false,
docId: doc.id,
actionsRef,
});
return (
<Box className="doc-tree-root-item-actions">
<Box
ref={actionsRef}
tabIndex={-1}
onKeyDownCapture={onKeyDownCapture}
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
@@ -176,11 +203,7 @@ export const DocTreeItemActions = ({
onOpenChange={onOpenChange}
>
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenChange?.(!isOpen);
}}
onClick={handleMoreOptionsClick}
iconName="more_horiz"
variant="filled"
$theme="primary"
@@ -189,28 +212,19 @@ export const DocTreeItemActions = ({
tabIndex={0}
role="button"
aria-label={t('More options')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onOpenChange?.(!isOpen);
}
}}
onKeyDown={handleMoreOptionsKeyDown}
/>
</DropdownMenu>
{doc.abilities.children_create && (
<BoxButton
as="button"
tabIndex={0}
data-testid="add-child-doc"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildDoc({
parentId: doc.id,
});
}}
onClick={handleAddChildClick}
onKeyDown={handleAddChildKeyDown}
color="primary"
aria-label={t('Add child document')}
$hasTransition={false}
>
<Icon
variant="filled"