✨(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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/frontend/apps/impress/src/hooks/index.ts
Normal file
1
src/frontend/apps/impress/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './useKeyboardAction';
|
||||||
22
src/frontend/apps/impress/src/hooks/useKeyboardAction.ts
Normal file
22
src/frontend/apps/impress/src/hooks/useKeyboardAction.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user