mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-08 16:12:26 +02:00
✨(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:
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user