From cfdd60420067af3886e29d47bbfe586274aecc24 Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 2 Dec 2025 12:13:07 +0100 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8(frontend)=20keyboard=20support?= =?UTF-8?q?=20in=20sub-documents=20with=20f2=20options=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds f2 shortcut to open options menu in sub-documents Signed-off-by: Cyril --- .../doc-tree/components/DocSubPageItem.tsx | 52 ++++++++---- .../components/DocTreeItemActions.tsx | 55 ++++++++++-- .../features/docs/doc-tree/dom-selectors.ts | 7 ++ .../docs/doc-tree/hooks/useActionableMode.ts | 83 +++++++++++++++++++ .../doc-tree/hooks/useKeyboardActivation.ts | 16 ++++ 5 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts 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 89325c3060..c1c525a3e4 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 @@ -20,6 +20,7 @@ import { import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; +import { useActionableMode } from '../hooks/useActionableMode'; import { useKeyboardActivation } from '../hooks/useKeyboardActivation'; import SubPageIcon from './../assets/sub-page-logo.svg'; @@ -106,6 +107,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const isSelected = isSelectedNow; const ariaLabel = docTitle; const isDisabled = !!doc.deleted_at; + const { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen); return ( ) => { aria-selected={isSelected} aria-expanded={hasChildren ? isExpanded : undefined} aria-disabled={isDisabled} + onKeyDownCapture={onKeyDownCapture} $css={css` background-color: var(--c--globals--colors--gray-000); .light-doc-item-actions { @@ -127,6 +130,13 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { outline: none !important; box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important; border-radius: var(--c--globals--spacings--st); + .light-doc-item-actions { + display: flex; + } + } + /* Retirer le focus visuel du tree item quand le focus est sur les actions */ + &:has(.light-doc-item-actions *:focus) .c__tree-view--node.isFocused { + box-shadow: none !important; } &:hover { background-color: var( @@ -137,6 +147,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { display: flex; } } + &:focus-within { + .light-doc-item-actions { + display: flex; + } + } .row.preview & { background-color: inherit; } @@ -153,6 +168,27 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { docId={doc.id} title={doc.title} /> + + + { e.stopPropagation(); @@ -168,6 +204,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $css={css` text-align: left; min-width: 0; + order: 1; `} > ) => { )} - - - ); 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 4ce70ef6a8..7fe22cde9a 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 @@ -1,10 +1,12 @@ import { DropdownMenu, DropdownMenuOption, + useArrowRoving, useTreeContext, } from '@gouvfr-lasuite/ui-kit'; import { useModal } from '@openfun/cunningham-react'; import { useRouter } from 'next/router'; +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -30,6 +32,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 = ({ @@ -39,7 +43,11 @@ export const DocTreeItemActions = ({ onCreateSuccess, onOpenChange, parentId, + actionsRef, + onKeyDownCapture, }: DocTreeItemActionsProps) => { + const internalActionsRef = useRef(null); + const targetActionsRef = actionsRef ?? internalActionsRef; const router = useRouter(); const { t } = useTranslation(); const deleteModal = useModal(); @@ -47,6 +55,9 @@ export const DocTreeItemActions = ({ const { mutate: detachDoc } = useDetachDoc(); const treeContext = useTreeContext(); + // Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight). + useArrowRoving(targetActionsRef.current); + const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: (duplicatedDoc) => { // Reset the tree context root will reset the full tree view. @@ -160,30 +171,53 @@ export const DocTreeItemActions = ({ }; return ( - + - { e.stopPropagation(); e.preventDefault(); onOpenChange?.(!isOpen); }} - iconName="more_horiz" - variant="filled" - $theme="brand" - $variation="secondary" aria-label={t('More options')} - /> + $css={css` + background: transparent; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:focus-visible { + outline: 2px solid var(--c--globals--colors--brand-500) !important; + outline-offset: -2px; + border-radius: var(--c--globals--spacings--st); + } + `} + > + + {doc.abilities.children_create && ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts new file mode 100644 index 0000000000..a260bc75d0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts @@ -0,0 +1,7 @@ +export const SELECTORS = { + MODAL: + '[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content', + ACTIONS_TOOLBAR: '.actions, .light-doc-item-actions', + INTERACTIVE_ELEMENTS: + 'button, a[href], input, textarea, select, [role="menuitem"], [role="button"]', +} as const; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts new file mode 100644 index 0000000000..ca8b66e15f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react'; + +import { SELECTORS } from '../dom-selectors'; + +export type ActionableNodeLike = { + isFocused?: boolean; + focus?: () => void; +}; + +export const useActionableMode = ( + node: ActionableNodeLike, + isMenuOpen?: boolean, +) => { + const actionsRef = useRef(null); + + useEffect(() => { + // Handles F2 to focus the first actionable element in the actions area, except when a modal is open + const toActions = (e: KeyboardEvent) => { + if (e.key !== 'F2' || document.querySelector(SELECTORS.MODAL)) { + return; + } + + // Only react if the node is currently focused + if (!node?.isFocused) { + return; + } + + const focusables = getFocusableElements(); + if (focusables.length === 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const first = focusables[0]; + // Ensure the element is focusable even if it's a
etc. + if ( + first instanceof HTMLElement && + !first.hasAttribute('tabindex') && + first.tabIndex === -1 + ) { + first.setAttribute('tabindex', '-1'); + } + + // TreeView may reclaim focus after this event cycle; setTimeout guarantees focus happens after + setTimeout(() => { + first.focus(); + }, 0); + }; + + document.addEventListener('keydown', toActions, true); + return () => document.removeEventListener('keydown', toActions, true); + // node is a dependency, as it's checked for focus state + }, [node]); + + // Returns all focusable action elements (buttons or role="button") inside the actionsRef + const getFocusableElements = () => { + if (!actionsRef.current) { + return []; + } + return Array.from( + actionsRef.current.querySelectorAll( + 'button, [role="button"]', + ), + ); + }; + + const onKeyDownCapture = (e: React.KeyboardEvent) => { + // Do nothing if the menu is open or a modal is displayed + if (isMenuOpen || document.querySelector(SELECTORS.MODAL)) { + return; + } + + // Escape: return focus to the tree node + if (e.key === 'Escape') { + e.stopPropagation(); + node?.focus?.(); + } + }; + + return { actionsRef, onKeyDownCapture }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts index 0a29aaf552..3ec3782035 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts @@ -1,5 +1,11 @@ import { useEffect } from 'react'; +import { SELECTORS } from '../dom-selectors'; + +/** + * Custom hook to activate an action with specific keyboard keys, + * unless focus is inside actions toolbar or on an interactive element. + */ export const useKeyboardActivation = ( keys: string[], enabled: boolean, @@ -12,6 +18,16 @@ export const useKeyboardActivation = ( return; } const onKeyDown = (e: KeyboardEvent) => { + // Ignore if the focus is inside the actions toolbar or on an interactive element + const target = e.target as HTMLElement | null; + if (target) { + const isInActions = target.closest(SELECTORS.ACTIONS_TOOLBAR); + const isInteractive = target.closest(SELECTORS.INTERACTIVE_ELEMENTS); + if (isInActions || isInteractive) { + return; + } + } + if (keys.includes(e.key)) { e.preventDefault(); action(); From f3cf6d06e30750116fa2df9d04c31a804c72eb5f Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 2 Dec 2025 13:05:07 +0100 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8(frontend)=20adds=20f2=20shortcu?= =?UTF-8?q?t=20using=20a=20fakenode=20since=20it's=20outside=20the=20treev?= =?UTF-8?q?iew=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cyril --- CHANGELOG.md | 2 + .../doc-tree/components/DocSubPageItem.tsx | 1 - .../docs/doc-tree/components/DocTree.tsx | 47 +++++++++++++++++-- .../components/DocTreeItemActions.tsx | 2 + 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36af2298a4..fedb175669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to ### Fixed - 🐛(nginx) fix / location to handle new static pages +- ♿(frontend) improve accessibility: + - ♿️Improve keyboard accessibility for the document tree #1681 ## [4.0.0] - 2025-12-01 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 c1c525a3e4..161a8ced45 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 @@ -204,7 +204,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $css={css` text-align: left; min-width: 0; - order: 1; `} > { const treeContext = useTreeContext(); const router = useRouter(); const [rootActionsOpen, setRootActionsOpen] = useState(false); + const [rootItemFocused, setRootItemFocused] = useState(false); const rootIsSelected = !!treeContext?.root?.id && treeContext?.treeData.selectedNode?.id === treeContext.root.id; + const rootItemRef = useRef(null); const { t } = useTranslation(); + /** + * Fake node to reuse useActionableMode hook for the root item. + * This allows F2 keyboard navigation and Escape handling on the root item + * without duplicating the logic from DocSubPageItem. + */ + const fakeRootNode: ActionableNodeLike = { + isFocused: rootItemFocused, + focus: () => { + rootItemRef.current?.focus(); + }, + }; + + const { actionsRef: rootActionsRef, onKeyDownCapture: onRootToolbarKeys } = + useActionableMode(fakeRootNode, rootActionsOpen); + const [initialOpenState, setInitialOpenState] = useState( undefined, ); @@ -85,12 +104,23 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { }, [router, treeContext?.root?.id]); const handleRootFocus = useCallback(() => { + setRootItemFocused(true); selectRoot(); }, [selectRoot]); - // activate root document with enter or space + const handleRootBlur = useCallback(() => { + setRootItemFocused(false); + }, []); + + // activate root document with enter or space (only when not in actions) const handleRootKeyDown = useCallback( (e: React.KeyboardEvent) => { + // Ignore if focus is in actions + const target = e.target as HTMLElement | null; + if (target?.closest('.doc-tree-root-item-actions')) { + return; + } + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectRoot(); @@ -206,12 +236,14 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { `} > { } .doc-tree-root-item-actions { - display: ${rootActionsOpen ? 'flex' : 'none'}; + display: flex; opacity: ${rootActionsOpen ? '1' : '0'}; &:has(.isOpen) { @@ -242,12 +274,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { } } &:hover, - &:focus-visible { + &:focus-visible, + &:focus-within { .doc-tree-root-item-actions { display: flex; opacity: 1; } } + /* Retirer le focus visuel du root item quand le focus est sur les actions */ + &:has(.doc-tree-root-item-actions *:focus) { + box-shadow: none !important; + } `} > { isOpen={rootActionsOpen} isRoot={true} onOpenChange={setRootActionsOpen} + actionsRef={rootActionsRef} + onKeyDownCapture={onRootToolbarKeys} /> 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 7fe22cde9a..8c046e1f86 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 @@ -195,6 +195,7 @@ export const DocTreeItemActions = ({ onOpenChange?.(!isOpen); }} aria-label={t('More options')} + tabIndex={-1} $css={css` background: transparent; border: none; @@ -233,6 +234,7 @@ export const DocTreeItemActions = ({ $variation="secondary" aria-label={t('Add a sub page')} data-testid="doc-tree-item-actions-add-child" + tabIndex={-1} $css={css` &:focus-visible { outline: 2px solid var(--c--globals--colors--brand-500) !important; From ef909b556ff7e8ebcc96b47b3118f1c8addf2593 Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 2 Dec 2025 14:09:29 +0100 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20sr-only=20ins?= =?UTF-8?q?tructions=20with=20aria-describedby=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improves screen reader support with contextual accessibility guidance Signed-off-by: Cyril --- .../src/features/docs/doc-tree/components/DocTree.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 48063b06ca..ec3f1b7fd2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -210,6 +210,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { $height="100%" role="tree" aria-label={t('Document tree')} + aria-describedby="doc-tree-keyboard-instructions" $css={css` /* Remove outline from TreeViewItem wrapper elements */ .c__tree-view--row { @@ -229,6 +230,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { } `} > + {/* Instructions clavier pour les lecteurs d'écran */} + + {t( + 'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to access document actions.', + )} + Date: Wed, 3 Dec 2025 09:08:57 +0100 Subject: [PATCH 04/14] fixup! adds f2 shortcut using a fakenode since it's outside the treeview struct --- .../doc-tree/components/DocSubPageItem.tsx | 31 ++++--- .../docs/doc-tree/components/DocTree.tsx | 39 +++------ .../components/DocTreeItemActions.tsx | 8 +- .../docs/doc-tree/hooks/useActionableMode.ts | 83 ------------------- .../doc-tree/hooks/useKeyboardActivation.ts | 45 ---------- 5 files changed, 32 insertions(+), 174 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts 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 161a8ced45..2a5d591faf 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 @@ -5,7 +5,7 @@ import { useTreeContext, } from '@gouvfr-lasuite/ui-kit'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -20,9 +20,6 @@ import { import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; -import { useActionableMode } from '../hooks/useActionableMode'; -import { useKeyboardActivation } from '../hooks/useKeyboardActivation'; - import SubPageIcon from './../assets/sub-page-logo.svg'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -47,7 +44,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const [menuOpen, setMenuOpen] = useState(false); const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id; - const isActive = node.isFocused || menuOpen || isSelectedNow; const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -93,21 +89,24 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { } }; - useKeyboardActivation( - ['Enter'], - isActive && !menuOpen, - handleActivate, - true, - '.c__tree-view', - ); - const docTitle = doc.title || untitledDocument; const hasChildren = (doc.children?.length || 0) > 0; const isExpanded = node.isOpen; const isSelected = isSelectedNow; const ariaLabel = docTitle; const isDisabled = !!doc.deleted_at; - const { actionsRef, onKeyDownCapture } = useActionableMode(node, menuOpen); + const actionsRef = useRef(null); + const buttonOptionRef = useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // F2: focus first action button + const shoulOpenActions = !menuOpen && node.isFocused; + if (e.key === 'F2' && shoulOpenActions) { + buttonOptionRef.current?.focus(); + e.stopPropagation(); + return; + } + }; return ( ) => { aria-selected={isSelected} aria-expanded={hasChildren ? isExpanded : undefined} aria-disabled={isDisabled} - onKeyDownCapture={onKeyDownCapture} + onKeyDown={handleKeyDown} $css={css` background-color: var(--c--globals--colors--gray-000); .light-doc-item-actions { @@ -186,7 +185,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { parentId={node.data.parentKey} onCreateSuccess={afterCreate} actionsRef={actionsRef} - onKeyDownCapture={onKeyDownCapture} + buttonOptionRef={buttonOptionRef} /> { const treeContext = useTreeContext(); const router = useRouter(); const [rootActionsOpen, setRootActionsOpen] = useState(false); - const [rootItemFocused, setRootItemFocused] = useState(false); const rootIsSelected = !!treeContext?.root?.id && treeContext?.treeData.selectedNode?.id === treeContext.root.id; const rootItemRef = useRef(null); + const rootActionsRef = useRef(null); + const rootButtonOptionRef = useRef(null); const { t } = useTranslation(); - /** - * Fake node to reuse useActionableMode hook for the root item. - * This allows F2 keyboard navigation and Escape handling on the root item - * without duplicating the logic from DocSubPageItem. - */ - const fakeRootNode: ActionableNodeLike = { - isFocused: rootItemFocused, - focus: () => { - rootItemRef.current?.focus(); - }, - }; - - const { actionsRef: rootActionsRef, onKeyDownCapture: onRootToolbarKeys } = - useActionableMode(fakeRootNode, rootActionsOpen); - const [initialOpenState, setInitialOpenState] = useState( undefined, ); @@ -104,17 +88,19 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { }, [router, treeContext?.root?.id]); const handleRootFocus = useCallback(() => { - setRootItemFocused(true); selectRoot(); }, [selectRoot]); - const handleRootBlur = useCallback(() => { - setRootItemFocused(false); - }, []); - - // activate root document with enter or space (only when not in actions) + // Handle keyboard navigation for root item const handleRootKeyDown = useCallback( (e: React.KeyboardEvent) => { + // F2: focus first action button + if (e.key === 'F2' && !rootActionsOpen) { + e.preventDefault(); + rootButtonOptionRef.current?.focus(); + return; + } + // Ignore if focus is in actions const target = e.target as HTMLElement | null; if (target?.closest('.doc-tree-root-item-actions')) { @@ -127,7 +113,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { navigateToRoot(); } }, - [selectRoot, navigateToRoot], + [selectRoot, navigateToRoot, rootActionsOpen], ); /** @@ -250,7 +236,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { aria-selected={rootIsSelected} tabIndex={0} onFocus={handleRootFocus} - onBlur={handleRootBlur} onKeyDown={handleRootKeyDown} $css={css` padding: ${spacingsTokens['2xs']}; @@ -327,7 +312,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { isRoot={true} onOpenChange={setRootActionsOpen} actionsRef={rootActionsRef} - onKeyDownCapture={onRootToolbarKeys} + buttonOptionRef={rootButtonOptionRef} /> 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 8c046e1f86..82cffde8ec 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 @@ -33,7 +33,7 @@ type DocTreeItemActionsProps = { onOpenChange?: (isOpen: boolean) => void; parentId?: string | null; actionsRef?: React.RefObject; - onKeyDownCapture?: (e: React.KeyboardEvent) => void; + buttonOptionRef?: React.RefObject; }; export const DocTreeItemActions = ({ @@ -44,10 +44,12 @@ export const DocTreeItemActions = ({ onOpenChange, parentId, actionsRef, - onKeyDownCapture, + buttonOptionRef, }: DocTreeItemActionsProps) => { const internalActionsRef = useRef(null); const targetActionsRef = actionsRef ?? internalActionsRef; + const internalButtonRef = useRef(null); + const targetButtonRef = buttonOptionRef ?? internalButtonRef; const router = useRouter(); const { t } = useTranslation(); const deleteModal = useModal(); @@ -174,7 +176,6 @@ export const DocTreeItemActions = ({ { e.stopPropagation(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts deleted file mode 100644 index ca8b66e15f..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useActionableMode.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { SELECTORS } from '../dom-selectors'; - -export type ActionableNodeLike = { - isFocused?: boolean; - focus?: () => void; -}; - -export const useActionableMode = ( - node: ActionableNodeLike, - isMenuOpen?: boolean, -) => { - const actionsRef = useRef(null); - - useEffect(() => { - // Handles F2 to focus the first actionable element in the actions area, except when a modal is open - const toActions = (e: KeyboardEvent) => { - if (e.key !== 'F2' || document.querySelector(SELECTORS.MODAL)) { - return; - } - - // Only react if the node is currently focused - if (!node?.isFocused) { - return; - } - - const focusables = getFocusableElements(); - if (focusables.length === 0) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - const first = focusables[0]; - // Ensure the element is focusable even if it's a
etc. - if ( - first instanceof HTMLElement && - !first.hasAttribute('tabindex') && - first.tabIndex === -1 - ) { - first.setAttribute('tabindex', '-1'); - } - - // TreeView may reclaim focus after this event cycle; setTimeout guarantees focus happens after - setTimeout(() => { - first.focus(); - }, 0); - }; - - document.addEventListener('keydown', toActions, true); - return () => document.removeEventListener('keydown', toActions, true); - // node is a dependency, as it's checked for focus state - }, [node]); - - // Returns all focusable action elements (buttons or role="button") inside the actionsRef - const getFocusableElements = () => { - if (!actionsRef.current) { - return []; - } - return Array.from( - actionsRef.current.querySelectorAll( - 'button, [role="button"]', - ), - ); - }; - - const onKeyDownCapture = (e: React.KeyboardEvent) => { - // Do nothing if the menu is open or a modal is displayed - if (isMenuOpen || document.querySelector(SELECTORS.MODAL)) { - return; - } - - // Escape: return focus to the tree node - if (e.key === 'Escape') { - e.stopPropagation(); - node?.focus?.(); - } - }; - - return { actionsRef, onKeyDownCapture }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts deleted file mode 100644 index 3ec3782035..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from 'react'; - -import { SELECTORS } from '../dom-selectors'; - -/** - * Custom hook to activate an action with specific keyboard keys, - * unless focus is inside actions toolbar or on an interactive element. - */ -export const useKeyboardActivation = ( - keys: string[], - enabled: boolean, - action: () => void, - capture = false, - selector: string, -) => { - useEffect(() => { - if (!enabled) { - return; - } - const onKeyDown = (e: KeyboardEvent) => { - // Ignore if the focus is inside the actions toolbar or on an interactive element - const target = e.target as HTMLElement | null; - if (target) { - const isInActions = target.closest(SELECTORS.ACTIONS_TOOLBAR); - const isInteractive = target.closest(SELECTORS.INTERACTIVE_ELEMENTS); - if (isInActions || isInteractive) { - return; - } - } - - if (keys.includes(e.key)) { - e.preventDefault(); - action(); - } - }; - const treeEl = document.querySelector(selector); - if (!treeEl) { - return; - } - treeEl.addEventListener('keydown', onKeyDown, capture); - return () => { - treeEl.removeEventListener('keydown', onKeyDown, capture); - }; - }, [keys, enabled, action, capture, selector]); -}; From 4982c969d69cd8e850c7588971920f94f3d673f6 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 3 Dec 2025 09:57:43 +0100 Subject: [PATCH 05/14] fixup! fixup! adds f2 shortcut using a fakenode since it's outside the treeview struct --- .../docs/doc-tree/components/DocSubPageItem.tsx | 16 +++++++++++++--- .../docs/doc-tree/components/DocTree.tsx | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) 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 2a5d591faf..8e523a7ecf 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 @@ -100,14 +100,24 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const handleKeyDown = (e: React.KeyboardEvent) => { // F2: focus first action button - const shoulOpenActions = !menuOpen && node.isFocused; - if (e.key === 'F2' && shoulOpenActions) { + const shouldOpenActions = !menuOpen && node.isFocused; + if (e.key === 'F2' && shouldOpenActions) { buttonOptionRef.current?.focus(); e.stopPropagation(); return; } }; + const handleActionsOpenChange = (isOpen: boolean) => { + setMenuOpen(isOpen); + + // When the menu closes (via Escape or activating an option), + // return focus to the tree item so focus is not lost. + if (!isOpen) { + node.focus(); + } + }; + return ( ) => { { [selectRoot, navigateToRoot, rootActionsOpen], ); + // Handle menu open/close for root item - mirrors DocSubPageItem behavior + const handleRootActionsOpenChange = useCallback((isOpen: boolean) => { + setRootActionsOpen(isOpen); + + // When the menu closes, return focus to the root tree item + // (same behavior as DocSubPageItem for consistency) + // Use requestAnimationFrame for smoother focus transition without flickering + if (!isOpen) { + requestAnimationFrame(() => { + rootItemRef.current?.focus(); + }); + } + }, []); + /** * This effect is used to reset the tree when a new document * that is not part of the current tree is loaded. @@ -310,7 +324,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { }} isOpen={rootActionsOpen} isRoot={true} - onOpenChange={setRootActionsOpen} + onOpenChange={handleRootActionsOpenChange} actionsRef={rootActionsRef} buttonOptionRef={rootButtonOptionRef} /> From eba4afa5974cd2fb4ab0cac3728e01ef839a285e Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 3 Dec 2025 13:51:03 +0100 Subject: [PATCH 06/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20adds=20f2?= =?UTF-8?q?=20shortcut=20using=20a=20fakenode=20since=20it's=20outside=20t?= =?UTF-8?q?he=20treeview=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-tree/components/DocSubPageItem.tsx | 2 +- .../src/features/docs/doc-tree/components/DocTree.tsx | 2 +- .../docs/doc-tree/components/DocTreeItemActions.tsx | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) 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 8e523a7ecf..01d1fac493 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 @@ -96,7 +96,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const ariaLabel = docTitle; const isDisabled = !!doc.deleted_at; const actionsRef = useRef(null); - const buttonOptionRef = useRef(null); + const buttonOptionRef = useRef(null); const handleKeyDown = (e: React.KeyboardEvent) => { // F2: focus first action button diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 60ba5b1aaa..9b56b401c9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -37,7 +37,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { treeContext?.treeData.selectedNode?.id === treeContext.root.id; const rootItemRef = useRef(null); const rootActionsRef = useRef(null); - const rootButtonOptionRef = useRef(null); + const rootButtonOptionRef = useRef(null); const { t } = useTranslation(); 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 82cffde8ec..ef4dcbcf32 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 @@ -33,7 +33,7 @@ type DocTreeItemActionsProps = { onOpenChange?: (isOpen: boolean) => void; parentId?: string | null; actionsRef?: React.RefObject; - buttonOptionRef?: React.RefObject; + buttonOptionRef?: React.RefObject; }; export const DocTreeItemActions = ({ @@ -48,7 +48,7 @@ export const DocTreeItemActions = ({ }: DocTreeItemActionsProps) => { const internalActionsRef = useRef(null); const targetActionsRef = actionsRef ?? internalActionsRef; - const internalButtonRef = useRef(null); + const internalButtonRef = useRef(null); const targetButtonRef = buttonOptionRef ?? internalButtonRef; const router = useRouter(); const { t } = useTranslation(); @@ -187,10 +187,8 @@ export const DocTreeItemActions = ({ isOpen={isOpen} onOpenChange={onOpenChange} > - { e.stopPropagation(); e.preventDefault(); @@ -220,7 +218,7 @@ export const DocTreeItemActions = ({ $theme="brand" $variation="secondary" /> - + {doc.abilities.children_create && ( Date: Mon, 8 Dec 2025 01:19:38 +0100 Subject: [PATCH 07/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20adds=20f2?= =?UTF-8?q?=20shortcut=20using=20a=20fakenode=20since=20it's=20outside=20t?= =?UTF-8?q?he=20treeview=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impress/src/features/docs/doc-tree/components/DocTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 9b56b401c9..b400779897 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -287,7 +287,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { opacity: 1; } } - /* Retirer le focus visuel du root item quand le focus est sur les actions */ + /* Remove visual focus from the root item when focus is on the actions */ &:has(.doc-tree-root-item-actions *:focus) { box-shadow: none !important; } From 9eecf387ce32eac0c6d218714a36c168729824c5 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 8 Dec 2025 02:10:35 +0100 Subject: [PATCH 08/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20add=20sr-o?= =?UTF-8?q?nly=20instructions=20with=20aria-describedby=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impress/src/features/docs/doc-tree/components/DocTree.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index b400779897..6b51c9d275 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -230,10 +230,10 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { } `} > - {/* Instructions clavier pour les lecteurs d'écran */} + {/* Keyboard instructions for screen readers */} {t( - 'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to access document actions.', + 'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.', )} Date: Mon, 8 Dec 2025 02:13:20 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fixup!=20fixup!=20=E2=9C=A8(frontend)=20a?= =?UTF-8?q?dds=20f2=20shortcut=20using=20a=20fakenode=20since=20it's=20out?= =?UTF-8?q?side=20the=20treeview=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-tree/components/DocSubPageItem.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 01d1fac493..fb7dab3cb8 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 @@ -143,8 +143,9 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { display: flex; } } - /* Retirer le focus visuel du tree item quand le focus est sur les actions */ - &:has(.light-doc-item-actions *:focus) .c__tree-view--node.isFocused { + /* Remove visual focus from the tree item when focus is on actions or emoji button */ + &:has(.light-doc-item-actions *:focus, .--docs--doc-icon:focus-visible) + .c__tree-view--node.isFocused { box-shadow: none !important; } &:hover { @@ -176,6 +177,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $size="sm" docId={doc.id} title={doc.title} + buttonProps={{ + $css: css` + &:focus-visible { + outline: 2px solid var(--c--globals--colors--brand-500); + outline-offset: 2px; + } + `, + }} /> Date: Mon, 8 Dec 2025 02:15:54 +0100 Subject: [PATCH 10/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20keyboard?= =?UTF-8?q?=20support=20in=20sub-documents=20with=20f2=20options=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impress/src/features/docs/doc-tree/dom-selectors.ts | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts deleted file mode 100644 index a260bc75d0..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/dom-selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const SELECTORS = { - MODAL: - '[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content', - ACTIONS_TOOLBAR: '.actions, .light-doc-item-actions', - INTERACTIVE_ELEMENTS: - 'button, a[href], input, textarea, select, [role="menuitem"], [role="button"]', -} as const; From fdc84b6bbe5aaac163ec70bb6edf503c14774f45 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 8 Dec 2025 02:48:47 +0100 Subject: [PATCH 11/14] =?UTF-8?q?fixup!=20fixup!=20fixup!=20=E2=9C=A8(fron?= =?UTF-8?q?tend)=20adds=20f2=20shortcut=20using=20a=20fakenode=20since=20i?= =?UTF-8?q?t's=20outside=20the=20treeview=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/docs/doc-tree/components/DocSubPageItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fb7dab3cb8..eb037d12c9 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 @@ -181,7 +181,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $css: css` &:focus-visible { outline: 2px solid var(--c--globals--colors--brand-500); - outline-offset: 2px; + outline-offset: var(--c--globals--spacings--4xs); } `, }} From 55b270bfe5752ced33e55199cdcba6cf67eb0343 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 8 Dec 2025 02:50:02 +0100 Subject: [PATCH 12/14] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20adds=20f2?= =?UTF-8?q?=20shortcut=20using=20a=20fakenode=20since=20it's=20outside=20t?= =?UTF-8?q?he=20treeview=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-tree/components/DocTreeItemActions.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) 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 ef4dcbcf32..c1a5b84188 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 @@ -196,17 +196,10 @@ export const DocTreeItemActions = ({ }} aria-label={t('More options')} tabIndex={-1} + $theme="brand" + $variation="secondary" $css={css` - background: transparent; - border: none; - padding: 0; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - &:focus-visible { - outline: 2px solid var(--c--globals--colors--brand-500) !important; outline-offset: -2px; border-radius: var(--c--globals--spacings--st); } @@ -237,7 +230,6 @@ export const DocTreeItemActions = ({ tabIndex={-1} $css={css` &:focus-visible { - outline: 2px solid var(--c--globals--colors--brand-500) !important; outline-offset: -2px; border-radius: var(--c--globals--spacings--st); } From 57a8f76a956babc2f526f7b3bc2fa4122bf2f2c1 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 8 Dec 2025 02:59:59 +0100 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=85(frontend)=20add=20e2e=20test=20?= =?UTF-8?q?to=20check=20focus=20behavior=20with=20F2=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensures F2 correctly focuses the expected UI element Signed-off-by: Cyril --- .../__tests__/app-impress/doc-tree.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index d43a174923..39af64097b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -303,6 +303,40 @@ test.describe('Doc Tree', () => { await expect(docTree.getByText(docChild)).toBeVisible(); }); + test('keyboard navigation with F2 focuses root actions button', async ({ + page, + browserName, + }) => { + // Create a parent document to initialize the tree + const [docParent] = await createDoc( + page, + 'doc-tree-keyboard-f2-root', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + const rootItem = page.getByTestId('doc-tree-root-item'); + await expect(rootItem).toBeVisible(); + + // Focus the root item + await rootItem.focus(); + await expect(rootItem).toBeFocused(); + + // Press F2 → focus should move to the root actions \"More options\" button + await page.keyboard.press('F2'); + + const rootActions = rootItem.locator('.doc-tree-root-item-actions'); + const rootMoreOptionsButton = rootActions.getByRole('button', { + name: /more options/i, + }); + + await expect(rootMoreOptionsButton).toBeFocused(); + }); + test('it updates the child icon from the tree', async ({ page, browserName, From dc9fbfc50a10081e36d3362c9b98e59cf8ed4d99 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 8 Dec 2025 12:34:33 +0100 Subject: [PATCH 14/14] fixup! fixup! adds f2 shortcut using a fakenode since it's outside the treeview struct --- .../doc-tree/components/DocSubPageItem.tsx | 12 ++++ .../doc-tree/hooks/useKeyboardActivation.ts | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts 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 eb037d12c9..e87f4d1f8b 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 @@ -20,6 +20,8 @@ import { import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; +import { useKeyboardActivation } from '../hooks/useKeyboardActivation'; + import SubPageIcon from './../assets/sub-page-logo.svg'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -44,6 +46,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const [menuOpen, setMenuOpen] = useState(false); const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id; + const isActive = node.isFocused || menuOpen || isSelectedNow; const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -98,6 +101,15 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const actionsRef = useRef(null); const buttonOptionRef = useRef(null); + // Active le document avec Enter/Espace via un handler global sur la tree-view + useKeyboardActivation( + ['Enter', ' '], + isActive && !menuOpen, + handleActivate, + true, + '.c__tree-view', + ); + const handleKeyDown = (e: React.KeyboardEvent) => { // F2: focus first action button const shouldOpenActions = !menuOpen && node.isFocused; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts new file mode 100644 index 0000000000..c768d9114e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useKeyboardActivation.ts @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; + +export const useKeyboardActivation = ( + keys: string[], + enabled: boolean, + action: () => void, + capture = false, + selector: string, +) => { + useEffect(() => { + if (!enabled) { + return; + } + + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + + // Limit activation to doc tree rows that actually contain a DocSubPageItem + const row = target.closest('.c__tree-view--row'); + if (!row || !row.querySelector('.--docs-sub-page-item')) { + return; + } + + // Do not hijack Enter/Space when focus is on emoji button or actions toolbar: + // in these cases we want the native button / dropdown behavior. + if ( + target.closest('.--docs--doc-icon') || + target.closest('.light-doc-item-actions') + ) { + return; + } + + if (!keys.includes(e.key)) { + return; + } + + e.preventDefault(); + action(); + }; + + const treeEl = document.querySelector(selector); + if (!treeEl) { + return; + } + + treeEl.addEventListener('keydown', onKeyDown, capture); + + return () => { + treeEl.removeEventListener('keydown', onKeyDown, capture); + }; + }, [keys, enabled, action, capture, selector]); +};