diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c1328f..a2460041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to ## [Unreleased] +### Changed + +- ♿️(frontend) keep focus after doc actions in menus #1857 + ## [v4.5.0] - 2026-01-28 ### Added diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index e6e76a98..0bd3e678 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -19,6 +19,22 @@ test.beforeEach(async ({ page }) => { await page.goto('/'); }); +/** + * Helper function to select an option from the format combobox in the export modal. + * Handles the auto-focus delay and ensures the dropdown is properly opened. + */ +const selectExportFormat = async (page: Page, option: string) => { + const combo = page.getByRole('combobox', { name: 'Format' }); + await expect(combo).toBeVisible(); + // Wait a bit for auto-focus to complete, then focus the combobox explicitly + await page.waitForTimeout(150); + await combo.focus(); + await combo.click(); + const listbox = page.getByRole('listbox'); + await expect(listbox).toBeVisible(); + await page.getByRole('option', { name: option }).click(); +}; + test.describe('Doc Export', () => { test('it check if all elements are visible', async ({ page, @@ -59,8 +75,7 @@ test.describe('Doc Export', () => { }) .click(); - await page.getByRole('combobox', { name: 'Format' }).click(); - await page.getByRole('option', { name: 'Docx' }).click(); + await selectExportFormat(page, 'Docx'); await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); @@ -89,8 +104,7 @@ test.describe('Doc Export', () => { }) .click(); - await page.getByRole('combobox', { name: 'Format' }).click(); - await page.getByRole('option', { name: 'Odt' }).click(); + await selectExportFormat(page, 'Odt'); await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); @@ -145,8 +159,7 @@ test.describe('Doc Export', () => { }) .click(); - await page.getByRole('combobox', { name: 'Format' }).click(); - await page.getByRole('option', { name: 'HTML' }).click(); + await selectExportFormat(page, 'HTML'); await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); diff --git a/src/frontend/apps/impress/src/components/DropButton.tsx b/src/frontend/apps/impress/src/components/DropButton.tsx index f3816d57..b9183b45 100644 --- a/src/frontend/apps/impress/src/components/DropButton.tsx +++ b/src/frontend/apps/impress/src/components/DropButton.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren, ReactNode, + RefObject, useEffect, useRef, useState, @@ -56,6 +57,7 @@ export interface DropButtonProps { onOpenChange?: (isOpen: boolean) => void; label?: string; testId?: string; + triggerRef?: RefObject; } export const DropButton = ({ @@ -66,17 +68,27 @@ export const DropButton = ({ children, label, testId, + triggerRef, }: PropsWithChildren) => { const { themeTokens } = useCunninghamTheme(); const font = themeTokens['font']?.['families']['base']; const [isLocalOpen, setIsLocalOpen] = useState(isOpen); - const triggerRef = useRef(null); + const internalTriggerRef = useRef(null); + const targetTriggerRef = triggerRef ?? internalTriggerRef; + const previousOpenRef = useRef(isOpen); useEffect(() => { setIsLocalOpen(isOpen); }, [isOpen]); + useEffect(() => { + if (previousOpenRef.current && !isLocalOpen) { + targetTriggerRef.current?.focus(); + } + previousOpenRef.current = isLocalOpen; + }, [isLocalOpen, targetTriggerRef]); + const onOpenChangeHandler = (isOpen: boolean) => { setIsLocalOpen(isOpen); onOpenChange?.(isOpen); @@ -85,7 +97,7 @@ export const DropButton = ({ return ( <> onOpenChangeHandler(true)} aria-label={label} data-testid={testId} @@ -99,7 +111,7 @@ export const DropButton = ({ void; testId?: string; + triggerRef?: RefObject; }; export const DropdownMenu = ({ @@ -56,6 +58,7 @@ export const DropdownMenu = ({ afterOpenChange, selectedValues, testId, + triggerRef, }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const keyboardAction = useKeyboardAction(); @@ -114,6 +117,7 @@ export const DropdownMenu = ({ label={label} buttonCss={buttonCss} testId={testId} + triggerRef={triggerRef} button={ showArrow ? ( { - return ( - diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx index 1501f81b..d9f418b8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx @@ -3,6 +3,7 @@ import { ODTExporter } from '@blocknote/xl-odt-exporter'; import { PDFExporter } from '@blocknote/xl-pdf-exporter'; import { Button, + ButtonElement, Loader, Modal, ModalSize, @@ -14,7 +15,7 @@ import { DocumentProps, pdf } from '@react-pdf/renderer'; import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' }; import i18next from 'i18next'; import JSZip from 'jszip'; -import { cloneElement, isValidElement, useState } from 'react'; +import { cloneElement, isValidElement, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -22,6 +23,7 @@ import { Box, ButtonCloseModal, Text } from '@/components'; import { useMediaUrl } from '@/core'; import { useEditorStore } from '@/docs/doc-editor'; import { Doc, useTrans } from '@/docs/doc-management'; +import { useModalAutoFocus } from '@/hooks'; import { fallbackLng } from '@/i18n/config'; import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; @@ -51,6 +53,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { t } = useTranslation(); const { toast } = useToastProvider(); const { editor } = useEditorStore(); + const downloadButtonRef = useRef(null); const [isExporting, setIsExporting] = useState(false); const [format, setFormat] = useState( DocDownloadFormat.PDF, @@ -58,6 +61,8 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { untitledDocument } = useTrans(); const mediaUrl = useMediaUrl(); + useModalAutoFocus(downloadButtonRef); + async function onSubmit() { if (!editor) { toast(t('The export failed'), VariantType.ERROR); @@ -202,6 +207,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { aria-label={t('Download')} variant="primary" fullWidth + ref={downloadButtonRef} onClick={() => void onSubmit()} disabled={isExporting} > diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 8c571d49..1308bed4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -2,7 +2,7 @@ import { Button, useModal } from '@gouvfr-lasuite/cunningham-react'; import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -34,6 +34,7 @@ import { KEY_LIST_DOC_VERSIONS, ModalSelectVersion, } from '@/docs/doc-versioning'; +import { useFocusMainContent } from '@/hooks'; import { useResponsiveStore } from '@/stores'; import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; @@ -59,12 +60,20 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const [isModalExportOpen, setIsModalExportOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); + const optionsTriggerRef = useRef(null); const { isSmallMobile, isMobile } = useResponsiveStore(); const copyDocLink = useCopyDocLink(doc.id); + + const focusMainContent = useFocusMainContent(); + const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: (data) => { - void router.push(`/docs/${data.id}`); + void router.push(`/docs/${data.id}`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }); }, }); const removeFavoriteDoc = useDeleteFavoriteDoc({ @@ -220,6 +229,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { { )} {isModalRemoveOpen && ( setIsModalRemoveOpen(false)} + onClose={() => { + setIsModalRemoveOpen(false); + optionsTriggerRef.current?.focus(); + }} doc={doc} onSuccess={() => { const isTopParent = doc.id === treeContext?.root?.id; @@ -254,9 +267,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { treeContext?.root?.id; if (isTopParent) { - void router.push(`/`); + void router.push(`/`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }); } else if (parentId) { void router.push(`/docs/${parentId}`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); setTimeout(() => { treeContext?.treeData.deleteNode(doc.id); }, 100); @@ -267,7 +287,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { )} {selectHistoryModal.isOpen && ( selectHistoryModal.close()} + onClose={() => { + selectHistoryModal.close(); + optionsTriggerRef.current?.focus(); + }} doc={doc} /> )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index eff4a77f..8d58bac9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -7,13 +7,13 @@ import { useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; import { useConfig } from '@/core'; import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid'; -import { useKeyboardAction } from '@/hooks'; +import { useKeyboardAction, useModalAutoFocus } from '@/hooks'; import { KEY_LIST_DOC } from '../api/useDocs'; import { useRemoveDoc } from '../api/useRemoveDoc'; @@ -61,17 +61,7 @@ export const ModalRemoveDoc = ({ }, }); - useEffect(() => { - const TIMEOUT_MODAL_MOUNTING = 100; - const timeoutId = setTimeout(() => { - const buttonElement = cancelButtonRef.current; - if (buttonElement) { - buttonElement.focus(); - } - }, TIMEOUT_MODAL_MOUNTING); - - return () => clearTimeout(timeoutId); - }, []); + useModalAutoFocus(cancelButtonRef); const keyboardAction = useKeyboardAction(); 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 fc2d5b1d..5c9bddd8 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 @@ -21,6 +21,7 @@ import { useDocTitleUpdate, } from '@/docs/doc-management'; import { useDuplicateDoc } from '@/docs/doc-management/api'; +import { useFocusMainContent } from '@/hooks'; import { useDetachDoc } from '../api/useDetach'; import MoveDocIcon from '../assets/doc-extract-bold.svg'; @@ -57,6 +58,8 @@ export const DocTreeItemActions = ({ const { mutate: detachDoc } = useDetachDoc(); const treeContext = useTreeContext(); + const focusMainContent = useFocusMainContent(); + // Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight). useArrowRoving(targetActionsRef.current); @@ -64,7 +67,11 @@ export const DocTreeItemActions = ({ onSuccess: (duplicatedDoc) => { // Reset the tree context root will reset the full tree view. treeContext?.setRoot(null); - void router.push(`/docs/${duplicatedDoc.id}`); + void router.push(`/docs/${duplicatedDoc.id}`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }); }, }); @@ -87,6 +94,9 @@ export const DocTreeItemActions = ({ if (treeContext.root) { treeContext.treeData.setSelectedNode(treeContext.root); void router.push(`/docs/${treeContext.root.id}`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); setTimeout(() => { treeContext?.treeData.deleteNode(doc.id); }, 100); @@ -162,9 +172,16 @@ export const DocTreeItemActions = ({ const parentIdComputed = parentId || treeContext?.root?.id; if (isTopParent) { - void router.push(`/`); + void router.push(`/`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }); } else if (parentIdComputed) { void router.push(`/docs/${parentIdComputed}`).then(() => { + requestAnimationFrame(() => { + focusMainContent(); + }); setTimeout(() => { treeContext?.treeData.deleteNode(doc.id); }, 100); @@ -241,7 +258,10 @@ export const DocTreeItemActions = ({ {deleteModal.isOpen && ( { + deleteModal.onClose(); + targetButtonRef.current?.focus(); + }} doc={doc} onSuccess={onSuccessDelete} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx index 6d9a30e2..0bf45d34 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx @@ -1,11 +1,13 @@ import { Button, + ButtonElement, Modal, ModalSize, VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Text } from '@/components'; @@ -15,6 +17,7 @@ import { useProviderStore, useUpdateDoc, } from '@/docs/doc-management/'; +import { useModalAutoFocus } from '@/hooks'; import { useDocVersion } from '../api'; import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions'; @@ -41,6 +44,9 @@ export const ModalConfirmationVersion = ({ const { toast } = useToastProvider(); const { push } = useRouter(); const { provider } = useProviderStore(); + const cancelButtonRef = useRef(null); + + useModalAutoFocus(cancelButtonRef); const { mutate: updateDoc } = useUpdateDoc({ listInvalidQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -77,6 +83,7 @@ export const ModalConfirmationVersion = ({ variant="secondary" fullWidth onClick={() => onClose()} + ref={cancelButtonRef} > {t('Cancel')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalSelectVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalSelectVersion.tsx index 39c4556e..b01ffdd3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalSelectVersion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalSelectVersion.tsx @@ -1,15 +1,17 @@ import { Button, + ButtonElement, Modal, ModalSize, useModal, } from '@gouvfr-lasuite/cunningham-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle, css } from 'styled-components'; import { Box, ButtonCloseModal, Text } from '@/components'; import { Doc } from '@/docs/doc-management'; +import { useModalAutoFocus } from '@/hooks'; import { Versions } from '../types'; @@ -43,6 +45,9 @@ export const ModalSelectVersion = ({ useState(); const canRestore = doc.abilities.partial_update; const restoreModal = useModal(); + const closeButtonRef = useRef(null); + + useModalAutoFocus(closeButtonRef); return ( <> @@ -134,6 +139,7 @@ export const ModalSelectVersion = ({ aria-label={t('Close the version history modal')} onClick={onClose} size="nano" + ref={closeButtonRef} /> diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 2758cd3b..73819580 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -1,4 +1,5 @@ import { useModal } from '@gouvfr-lasuite/cunningham-react'; +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -12,20 +13,33 @@ import { useDeleteFavoriteDoc, useDuplicateDoc, } from '@/docs/doc-management'; +import { useFocusMainContent } from '@/hooks'; interface DocsGridActionsProps { doc: Doc; openShareModal?: () => void; + triggerRef?: React.RefObject; } export const DocsGridActions = ({ doc, openShareModal, + triggerRef, }: DocsGridActionsProps) => { const { t } = useTranslation(); const deleteModal = useModal(); - const { mutate: duplicateDoc } = useDuplicateDoc(); + const menuTriggerRef = useRef(null); + + const focusMainContent = useFocusMainContent(); + + const { mutate: duplicateDoc } = useDuplicateDoc({ + onSuccess: () => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }, + }); const removeFavoriteDoc = useDeleteFavoriteDoc({ listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC], @@ -90,6 +104,7 @@ export const DocsGridActions = ({ options={options} label={menuLabel} aria-label={t('More options')} + triggerRef={triggerRef ?? menuTriggerRef} buttonCss={css` &:hover { background-color: unset; @@ -112,7 +127,18 @@ export const DocsGridActions = ({ {deleteModal.isOpen && ( - + { + deleteModal.onClose(); + menuTriggerRef.current?.focus(); + }} + doc={doc} + onSuccess={() => { + requestAnimationFrame(() => { + focusMainContent(); + }); + }} + /> )} ); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx index 1d82edea..ab08694b 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx @@ -1,6 +1,6 @@ import { Tooltip, useModal } from '@gouvfr-lasuite/cunningham-react'; import { useSearchParams } from 'next/navigation'; -import { KeyboardEvent } from 'react'; +import { KeyboardEvent, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -33,6 +33,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const { flexLeft, flexRight } = useResponsiveDocGrid(); const { spacingsTokens } = useCunninghamTheme(); const shareModal = useModal(); + const shareTriggerRef = useRef(null); const isPublic = doc.link_reach === LinkReach.PUBLIC; const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED; const isShared = isPublic || isAuthenticated; @@ -182,13 +183,23 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { {isInTrashbin ? ( ) : ( - + )} {shareModal.isOpen && ( - + { + shareModal.close(); + shareTriggerRef.current?.focus(); + }} + /> )} ); diff --git a/src/frontend/apps/impress/src/hooks/index.ts b/src/frontend/apps/impress/src/hooks/index.ts index 340eaa5d..cdcfa757 100644 --- a/src/frontend/apps/impress/src/hooks/index.ts +++ b/src/frontend/apps/impress/src/hooks/index.ts @@ -1,4 +1,6 @@ export * from './useClipboard'; export * from './useCmdK'; export * from './useDate'; +export * from './useFocusMainContent'; export * from './useKeyboardAction'; +export * from './useModalAutoFocus'; diff --git a/src/frontend/apps/impress/src/hooks/useFocusMainContent.ts b/src/frontend/apps/impress/src/hooks/useFocusMainContent.ts new file mode 100644 index 00000000..9c0449b7 --- /dev/null +++ b/src/frontend/apps/impress/src/hooks/useFocusMainContent.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react'; + +import { MAIN_LAYOUT_ID } from '@/layouts/conf'; + +export const useFocusMainContent = () => { + return useCallback(() => { + const mainContent = document.getElementById(MAIN_LAYOUT_ID); + if (!mainContent) { + return; + } + + mainContent.focus(); + mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); +}; diff --git a/src/frontend/apps/impress/src/hooks/useModalAutoFocus.ts b/src/frontend/apps/impress/src/hooks/useModalAutoFocus.ts new file mode 100644 index 00000000..f7ca6f02 --- /dev/null +++ b/src/frontend/apps/impress/src/hooks/useModalAutoFocus.ts @@ -0,0 +1,21 @@ +import { DependencyList, RefObject, useEffect } from 'react'; + +const DEFAULT_DEPS: DependencyList = []; + +interface UseModalAutoFocusOptions { + delay?: number; + deps?: DependencyList; +} + +export const useModalAutoFocus = ( + ref: RefObject, + { delay = 100, deps = DEFAULT_DEPS }: UseModalAutoFocusOptions = {}, +) => { + useEffect(() => { + const timeoutId = setTimeout(() => { + ref.current?.focus(); + }, delay); + + return () => clearTimeout(timeoutId); + }, [ref, delay, deps]); +};