mirror of
https://github.com/suitenumerique/docs
synced 2026-04-21 13:37:20 +00:00
♿️(frontend) keep focus after doc actions in menus
Restore focus to main content after duplicate/delete/move actions.
This commit is contained in:
parent
709076067b
commit
582db720db
17 changed files with 238 additions and 58 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export * from './useClipboard';
|
||||
export * from './useCmdK';
|
||||
export * from './useDate';
|
||||
export * from './useFocusMainContent';
|
||||
export * from './useKeyboardAction';
|
||||
export * from './useModalAutoFocus';
|
||||
|
|
|
|||
15
src/frontend/apps/impress/src/hooks/useFocusMainContent.ts
Normal file
15
src/frontend/apps/impress/src/hooks/useFocusMainContent.ts
Normal 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' });
|
||||
}, []);
|
||||
};
|
||||
21
src/frontend/apps/impress/src/hooks/useModalAutoFocus.ts
Normal file
21
src/frontend/apps/impress/src/hooks/useModalAutoFocus.ts
Normal 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]);
|
||||
};
|
||||
Loading…
Reference in a new issue