diff --git a/src/frontend/apps/impress/src/components/dropdown-menu/hook/useActionableMode.ts b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useActionableMode.ts new file mode 100644 index 000000000..784250cb5 --- /dev/null +++ b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useActionableMode.ts @@ -0,0 +1,97 @@ +import { TreeDataItem } from '@gouvfr-lasuite/ui-kit'; +import { useEffect, useRef } from 'react'; +import type { NodeRendererProps } from 'react-arborist'; + +type FocusableNode = NodeRendererProps>['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 = ( + node: FocusableNode, + isMenuOpen?: boolean, +) => { + const actionsRef = useRef(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( + '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( + '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 }; +}; diff --git a/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDocTreeItemHandlers.ts b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDocTreeItemHandlers.ts new file mode 100644 index 000000000..b22b7026a --- /dev/null +++ b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDocTreeItemHandlers.ts @@ -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, + }; +}; diff --git a/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDropdownFocusManagement.ts b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDropdownFocusManagement.ts new file mode 100644 index 000000000..acc8038d6 --- /dev/null +++ b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useDropdownFocusManagement.ts @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; + +interface UseDropdownFocusManagementProps { + isOpen: boolean; + docId: string; + actionsRef?: React.RefObject; +} + +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( + '[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( + '[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( + '[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]); +}; diff --git a/src/frontend/apps/impress/src/components/dropdown-menu/hook/useTreeItemKeyboardActivate.tsx b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useTreeItemKeyboardActivate.tsx new file mode 100644 index 000000000..d0208af7f --- /dev/null +++ b/src/frontend/apps/impress/src/components/dropdown-menu/hook/useTreeItemKeyboardActivate.tsx @@ -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]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 720facd6c..2cd40fdb9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -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) => { 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) => { 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) => { } }; + useTreeItemKeyboardActivate(isActive, handleActivate); + return ( ) => { } `} > - { - treeContext?.treeData.setSelectedNode(props.node.data.value as Doc); - router.push(`/docs/${props.node.data.value.id}`); - }} - > - + { + 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}`} > } $size="sm" /> @@ -163,25 +177,28 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { iconName="group" $size="16px" $variation="400" + aria-hidden="true" /> )} - - - - - + + + + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 12cd2dfa7..5773a9052 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -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; + 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 ( { - 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} /> {doc.abilities.children_create && ( { - e.stopPropagation(); - e.preventDefault(); - - createChildDoc({ - parentId: doc.id, - }); - }} + onClick={handleAddChildClick} + onKeyDown={handleAddChildKeyDown} color="primary" aria-label={t('Add child document')} + $hasTransition={false} >