️(frontend) keep focus after doc actions in menus

Restore focus to main content after duplicate/delete/move actions.
This commit is contained in:
Cyril 2026-02-02 13:24:37 +01:00
parent 709076067b
commit 582db720db
No known key found for this signature in database
GPG key ID: D5E8474B0AB0064A
17 changed files with 238 additions and 58 deletions

View file

@ -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

View file

@ -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();

View file

@ -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<HTMLButtonElement | null>;
}
export const DropButton = ({
@ -66,17 +68,27 @@ export const DropButton = ({
children,
label,
testId,
triggerRef,
}: PropsWithChildren<DropButtonProps>) => {
const { themeTokens } = useCunninghamTheme();
const font = themeTokens['font']?.['families']['base'];
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef(null);
const internalTriggerRef = useRef<HTMLButtonElement>(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 (
<>
<StyledButton
ref={triggerRef}
ref={targetTriggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
data-testid={testId}
@ -99,7 +111,7 @@ export const DropButton = ({
</StyledButton>
<StyledPopover
triggerRef={triggerRef}
triggerRef={targetTriggerRef}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
className="--docs--drop-button-popover"

View file

@ -3,6 +3,7 @@ import {
Fragment,
PropsWithChildren,
ReactNode,
RefObject,
useCallback,
useEffect,
useRef,
@ -41,6 +42,7 @@ export type DropdownMenuProps = {
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
testId?: string;
triggerRef?: RefObject<HTMLButtonElement | null>;
};
export const DropdownMenu = ({
@ -56,6 +58,7 @@ export const DropdownMenu = ({
afterOpenChange,
selectedValues,
testId,
triggerRef,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const keyboardAction = useKeyboardAction();
@ -114,6 +117,7 @@ export const DropdownMenu = ({
label={label}
buttonCss={buttonCss}
testId={testId}
triggerRef={triggerRef}
button={
showArrow ? (
<Box

View file

@ -1,23 +1,32 @@
import { Button, type ButtonProps } from '@gouvfr-lasuite/cunningham-react';
import React from 'react';
import {
Button,
ButtonElement,
type ButtonProps,
} from '@gouvfr-lasuite/cunningham-react';
import React, { forwardRef } from 'react';
import { Icon } from '@/components';
export const ButtonCloseModal = (props: ButtonProps) => {
return (
<Button
type="button"
size="small"
color="brand"
variant="tertiary"
icon={
<Icon
$withThemeInherited
iconName="close"
className="material-icons-filled"
/>
}
{...props}
/>
);
};
export const ButtonCloseModal = forwardRef<ButtonElement, ButtonProps>(
(props, ref) => {
return (
<Button
ref={ref}
type="button"
size="small"
color="brand"
variant="tertiary"
icon={
<Icon
$withThemeInherited
iconName="close"
className="material-icons-filled"
/>
}
{...props}
/>
);
},
);
ButtonCloseModal.displayName = 'ButtonCloseModal';

View file

@ -1,7 +1,14 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import {
Button,
ButtonElement,
Modal,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useModalAutoFocus } from '@/hooks';
interface ModalConfirmDownloadUnsafeProps {
onClose: () => void;
@ -13,6 +20,9 @@ export const ModalConfirmDownloadUnsafe = ({
onClose,
}: ModalConfirmDownloadUnsafeProps) => {
const { t } = useTranslation();
const cancelButtonRef = useRef<ButtonElement>(null);
useModalAutoFocus(cancelButtonRef);
return (
<Modal
@ -26,6 +36,7 @@ export const ModalConfirmDownloadUnsafe = ({
aria-label={t('Cancel the download')}
variant="secondary"
onClick={() => onClose()}
ref={cancelButtonRef}
>
{t('Cancel')}
</Button>

View file

@ -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<ButtonElement>(null);
const [isExporting, setIsExporting] = useState(false);
const [format, setFormat] = useState<DocDownloadFormat>(
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}
>

View file

@ -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<HTMLButtonElement>(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) => {
<DropdownMenu
options={options}
label={t('Open the document options')}
triggerRef={optionsTriggerRef}
buttonCss={css`
padding: ${spacingsTokens['xs']};
${isSmallMobile
@ -245,7 +255,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
)}
{isModalRemoveOpen && (
<ModalRemoveDoc
onClose={() => 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 && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
onClose={() => {
selectHistoryModal.close();
optionsTriggerRef.current?.focus();
}}
doc={doc}
/>
)}

View file

@ -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();

View file

@ -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<Doc | null>();
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 = ({
</Box>
{deleteModal.isOpen && (
<ModalRemoveDoc
onClose={deleteModal.onClose}
onClose={() => {
deleteModal.onClose();
targetButtonRef.current?.focus();
}}
doc={doc}
onSuccess={onSuccessDelete}
/>

View file

@ -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<ButtonElement>(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')}
</Button>

View file

@ -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<Versions['version_id']>();
const canRestore = doc.abilities.partial_update;
const restoreModal = useModal();
const closeButtonRef = useRef<ButtonElement>(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}
/>
</Box>

View file

@ -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<HTMLButtonElement | null>;
}
export const DocsGridActions = ({
doc,
openShareModal,
triggerRef,
}: DocsGridActionsProps) => {
const { t } = useTranslation();
const deleteModal = useModal();
const { mutate: duplicateDoc } = useDuplicateDoc();
const menuTriggerRef = useRef<HTMLButtonElement>(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 = ({
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveDoc onClose={deleteModal.onClose} doc={doc} />
<ModalRemoveDoc
onClose={() => {
deleteModal.onClose();
menuTriggerRef.current?.focus();
}}
doc={doc}
onSuccess={() => {
requestAnimationFrame(() => {
focusMainContent();
});
}}
/>
)}
</>
);

View file

@ -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<HTMLButtonElement>(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 ? (
<DocsGridTrashbinActions doc={doc} />
) : (
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
<DocsGridActions
doc={doc}
openShareModal={handleShareClick}
triggerRef={shareTriggerRef}
/>
)}
</Box>
</Box>
</Box>
{shareModal.isOpen && (
<DocShareModal doc={doc} onClose={shareModal.close} />
<DocShareModal
doc={doc}
onClose={() => {
shareModal.close();
shareTriggerRef.current?.focus();
}}
/>
)}
</>
);

View file

@ -1,4 +1,6 @@
export * from './useClipboard';
export * from './useCmdK';
export * from './useDate';
export * from './useFocusMainContent';
export * from './useKeyboardAction';
export * from './useModalAutoFocus';

View file

@ -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' });
}, []);
};

View file

@ -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<HTMLElement | null>,
{ delay = 100, deps = DEFAULT_DEPS }: UseModalAutoFocusOptions = {},
) => {
useEffect(() => {
const timeoutId = setTimeout(() => {
ref.current?.focus();
}, delay);
return () => clearTimeout(timeoutId);
}, [ref, delay, deps]);
};