(frontend) add focus trap and enter key support to remove doc modal

improves a11y by enabling keyboard-triggered modal with proper focus trap

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-10-29 13:05:23 +01:00
parent 3e410e3519
commit 6314cb3a18
5 changed files with 73 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519 - ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528 - ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(docx) fix image overflow by limiting width to 600px during export #1525 - 🐛(docx) fix image overflow by limiting width to 600px during export #1525
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512 - 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
- 🐛(frontend) fix pdf embed to use full width #1526 - 🐛(frontend) fix pdf embed to use full width #1526

View File

@@ -12,6 +12,7 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components'; import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { useKeyboardAction } from '@/hooks';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav'; import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
@@ -57,6 +58,7 @@ export const DropdownMenu = ({
testId, testId,
}: PropsWithChildren<DropdownMenuProps>) => { }: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const keyboardAction = useKeyboardAction();
const [isOpen, setIsOpen] = useState(opened ?? false); const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null); const blockButtonRef = useRef<HTMLDivElement>(null);
@@ -93,6 +95,14 @@ export const DropdownMenu = ({
} }
}, [isOpen, options]); }, [isOpen, options]);
const triggerOption = useCallback(
(option: DropdownMenuOption) => {
onOpenChange?.(false);
void option.callback?.();
},
[onOpenChange],
);
if (disabled) { if (disabled) {
return children; return children;
} }
@@ -170,9 +180,9 @@ export const DropdownMenu = ({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onOpenChange?.(false); triggerOption(option);
void option.callback?.();
}} }}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label} key={option.label}
$align="center" $align="center"
$justify="space-between" $justify="space-between"

View File

@@ -1,16 +1,19 @@
import { import {
Button, Button,
ButtonElement,
Modal, Modal,
ModalSize, ModalSize,
VariantType, VariantType,
useToastProvider, useToastProvider,
} from '@openfun/cunningham-react'; } from '@openfun/cunningham-react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useConfig } from '@/core'; import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid'; import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useKeyboardAction } from '@/hooks';
import { KEY_LIST_DOC } from '../api/useDocs'; import { KEY_LIST_DOC } from '../api/useDocs';
import { useRemoveDoc } from '../api/useRemoveDoc'; import { useRemoveDoc } from '../api/useRemoveDoc';
@@ -34,6 +37,7 @@ export const ModalRemoveDoc = ({
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30; const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
const { push } = useRouter(); const { push } = useRouter();
const { hasChildren } = useDocUtils(doc); const { hasChildren } = useDocUtils(doc);
const cancelButtonRef = useRef<ButtonElement>(null);
const { const {
mutate: removeDoc, mutate: removeDoc,
isError, isError,
@@ -57,20 +61,47 @@ 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);
}, []);
const keyboardAction = useKeyboardAction();
const handleClose = () => {
onClose();
};
const handleDelete = () => {
removeDoc({ docId: doc.id });
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleDeleteKeyDown = keyboardAction(handleDelete);
return ( return (
<Modal <Modal
isOpen isOpen
closeOnClickOutside closeOnClickOutside
hideCloseButton hideCloseButton
onClose={() => onClose()} onClose={handleClose}
aria-describedby="modal-remove-doc-title" aria-describedby="modal-remove-doc-title"
rightActions={ rightActions={
<> <>
<Button <Button
ref={cancelButtonRef}
aria-label={t('Cancel the deletion')} aria-label={t('Cancel the deletion')}
color="secondary" color="secondary"
fullWidth fullWidth
onClick={() => onClose()} onClick={handleClose}
onKeyDown={handleCloseKeyDown}
> >
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -78,11 +109,8 @@ export const ModalRemoveDoc = ({
aria-label={t('Delete document')} aria-label={t('Delete document')}
color="danger" color="danger"
fullWidth fullWidth
onClick={() => onClick={handleDelete}
removeDoc({ onKeyDown={handleDeleteKeyDown}
docId: doc.id,
})
}
> >
{t('Delete')} {t('Delete')}
</Button> </Button>
@@ -108,7 +136,8 @@ export const ModalRemoveDoc = ({
</Text> </Text>
<ButtonCloseModal <ButtonCloseModal
aria-label={t('Close the delete modal')} aria-label={t('Close the delete modal')}
onClick={() => onClose()} onClick={handleClose}
onKeyDown={handleCloseKeyDown}
/> />
</Box> </Box>
} }

View File

@@ -0,0 +1 @@
export * from './useKeyboardAction';

View File

@@ -0,0 +1,22 @@
import { KeyboardEvent, useCallback } from 'react';
type KeyboardActionCallback = () => void | Promise<unknown>;
type KeyboardActionHandler = (event: KeyboardEvent<HTMLElement>) => void;
/**
* Hook to create keyboard handlers that trigger the provided callback
* when the user presses Enter or Space.
*/
export const useKeyboardAction = () => {
return useCallback(
(callback: KeyboardActionCallback): KeyboardActionHandler =>
(event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void callback();
}
},
[],
);
};