From 9cdd0bd7f96d779cf0b9d2d8d314850f74ec8e2e Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 12 Aug 2025 17:51:11 +0200 Subject: [PATCH] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20add=20keyboard?= =?UTF-8?q?=20navigation=20for=20subdocs=20with=20focus=20activation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc-tree/components/DocSubPageItem.tsx | 30 ++++++++-- .../components/DocTreeItemActions.tsx | 2 +- .../doc-tree/hooks/useLoadChildrenOnOpen.ts | 55 +++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useLoadChildrenOnOpen.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 734dbf9f8..a2190a49b 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 @@ -17,6 +17,7 @@ import { } from '@/features/docs/doc-management'; import { DocIcon } from '@/features/docs/doc-management/components/DocIcon'; import { useActionableMode } from '@/features/docs/doc-tree/hooks/useActionableMode'; +import { useLoadChildrenOnOpen } from '@/features/docs/doc-tree/hooks/useLoadChildrenOnOpen'; import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; @@ -45,8 +46,8 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); - - const isActive = node.isFocused || menuOpen; + const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id; + const isActive = node.isFocused || menuOpen || isSelectedNow; const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -89,13 +90,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { } }; - useKeyboardActivation(['Enter', ' '], isActive, handleActivate, true); + useKeyboardActivation( + ['Enter', ' '], + isActive && !menuOpen, + handleActivate, + true, + ); + useLoadChildrenOnOpen( + node.data.value.id, + node.isOpen, + treeContext?.treeData.handleLoadChildren, + treeContext?.treeData.setChildren, + (doc.children?.length ?? 0) > 0 || doc.childrenCount === 0, + ); // prepare the text for the screen reader const docTitle = doc.title || untitledDocument; const hasChildren = (doc.children?.length || 0) > 0; const isExpanded = node.isOpen; - const isSelected = treeContext?.treeData.selectedNode?.id === doc.id; + const isSelected = isSelectedNow; const ariaLabel = `${docTitle}${hasChildren ? `, ${isExpanded ? t('expanded') : t('collapsed')}` : ''}${isSelected ? `, ${t('selected')}` : ''}`; @@ -155,6 +168,15 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { .row.preview & { background-color: inherit; } + + /* Ensure actions are visible when hovering the whole item container */ + &:hover { + .light-doc-item-actions { + display: flex; + opacity: 1; + visibility: visible; + } + } `} > 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 1c1e3ccbd..211cf78f2 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 @@ -151,7 +151,7 @@ export const DocTreeItemActions = ({ }; useDropdownFocusManagement({ - isOpen: isOpen || false, + isOpen: !!isOpen, docId: doc.id, actionsRef, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useLoadChildrenOnOpen.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useLoadChildrenOnOpen.ts new file mode 100644 index 000000000..b6e2603f2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useLoadChildrenOnOpen.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; + +/** + * Lazily loads children for a tree node the first time it is expanded. + * Works for both mouse and keyboard expansions. + */ +export const useLoadChildrenOnOpen = ( + nodeId: string, + isOpen: boolean, + handleLoadChildren?: (id: string) => Promise, + setChildren?: (id: string, children: T[]) => void, + isAlreadyLoaded?: boolean, +) => { + const hasLoadedRef = useRef(false); + + // Reset the local loaded flag when the node id changes + useEffect(() => { + hasLoadedRef.current = false; + }, [nodeId]); + + useEffect(() => { + if (!isOpen) { + return; + } + if (isAlreadyLoaded) { + hasLoadedRef.current = true; + return; + } + if (hasLoadedRef.current) { + return; + } + if (!handleLoadChildren || !setChildren) { + return; + } + + let isCancelled = false; + // Mark as loading to prevent repeated fetches/renders that can cause flicker + hasLoadedRef.current = true; + void handleLoadChildren(nodeId) + .then((children) => { + if (isCancelled) { + return; + } + setChildren(nodeId, children); + }) + .catch(() => { + // allow retry on next open + hasLoadedRef.current = false; + }); + + return () => { + isCancelled = true; + }; + }, [isOpen, nodeId, handleLoadChildren, setChildren, isAlreadyLoaded]); +};