diff --git a/front/src/common/Map/Search/MapSearchStation.tsx b/front/src/common/Map/Search/MapSearchStation.tsx index b3158dc6c20..3a1c1fe3a2a 100644 --- a/front/src/common/Map/Search/MapSearchStation.tsx +++ b/front/src/common/Map/Search/MapSearchStation.tsx @@ -149,6 +149,7 @@ const MapSearchStation = ({ updateExtViewport, closeMapSearchPopUp }: MapSearchS clearButton noMargin sm + focus /> diff --git a/front/src/modules/project/components/AddOrEditProjectModal.tsx b/front/src/modules/project/components/AddOrEditProjectModal.tsx index 2ba882a1f02..84c3c50d74b 100644 --- a/front/src/modules/project/components/AddOrEditProjectModal.tsx +++ b/front/src/modules/project/components/AddOrEditProjectModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -30,6 +30,7 @@ import type { ProjectWithStudies, ProjectCreateForm } from 'common/api/osrdEdito import { setFailure, setSuccess } from 'reducers/main'; import { getUserSafeWord } from 'reducers/user/userSelectors'; +import useModalFocusTrap from 'utils/hooks/useModalFocusTrap'; const emptyProject: ProjectCreateForm = { description: '', @@ -71,6 +72,8 @@ export default function AddOrEditProjectModal({ const { updateProjectID } = useOsrdConfActions(); + const modalRef = useRef(null); + const removeTag = (idx: number) => { if (!currentProject.tags) return; const newTags = Array.from(currentProject.tags); @@ -217,8 +220,10 @@ export default function AddOrEditProjectModal({ } }, [safeWord]); + useModalFocusTrap(modalRef, closeModal); + return ( -
+

{editionMode ? t('projectModificationTitle') : t('projectCreationTitle')} @@ -242,6 +247,7 @@ export default function AddOrEditProjectModal({ type="text" name="projectInputName" data-testid="projectInputName" + focus label={
diff --git a/front/src/modules/rollingStock/components/RollingStockEditor/PowerRestrictionGridModal.tsx b/front/src/modules/rollingStock/components/RollingStockEditor/PowerRestrictionGridModal.tsx index 39bae00bd03..3559050d6f2 100644 --- a/front/src/modules/rollingStock/components/RollingStockEditor/PowerRestrictionGridModal.tsx +++ b/front/src/modules/rollingStock/components/RollingStockEditor/PowerRestrictionGridModal.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Grid from 'common/Grid/Grid'; import InputSNCF from 'common/BootstrapSNCF/InputSNCF'; import { ModalBodySNCF, ModalHeaderSNCF, useModal } from 'common/BootstrapSNCF/ModalSNCF'; import { splitArrayByFirstLetter } from 'utils/array'; +import useModalFocusTrap from 'utils/hooks/useModalFocusTrap'; type PowerRestrictionGridModalProps = { powerRestrictionsList: string[]; @@ -35,50 +36,7 @@ const PowerRestrictionGridModal = ({ closeModal(); }; - // Allow the user to escape the modal by pressing escape and to trap the focus inside it - useEffect(() => { - const modalElement = modalRef.current; - - const focusableElements = modalElement?.querySelectorAll( - // last declaration stands for all elements not natively focusable like li - 'input, button, [tabindex]:not([tabindex="-1"])' - ) as NodeListOf; - - const firstElement = focusableElements[0] as HTMLElement; - const lastElement = focusableElements[focusableElements?.length - 1] as HTMLElement; - - /** - * - * Prevent the tab event and set focus on : - * - last element if we are pressing on "shift" in addition to "tab" and are on the first element - * - first element if we are only pressing "tab" and are on the last element - */ - const handleTabKeyPress = (event: KeyboardEvent) => { - if (event.key === 'Tab') { - if (event.shiftKey && document.activeElement === firstElement) { - event.preventDefault(); - lastElement.focus(); - } else if (!event.shiftKey && document.activeElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } - } - }; - - const handleEscapeKeyPress = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeModal(); - } - }; - - modalElement?.addEventListener('keydown', handleTabKeyPress); - modalElement?.addEventListener('keydown', handleEscapeKeyPress); - - return () => { - modalElement?.removeEventListener('keydown', handleTabKeyPress); - modalElement?.removeEventListener('keydown', handleEscapeKeyPress); - }; - }, []); + useModalFocusTrap(modalRef, closeModal); return (
diff --git a/front/src/modules/scenario/components/AddOrEditScenarioModal.tsx b/front/src/modules/scenario/components/AddOrEditScenarioModal.tsx index 48db8fb08e5..d826c324d14 100644 --- a/front/src/modules/scenario/components/AddOrEditScenarioModal.tsx +++ b/front/src/modules/scenario/components/AddOrEditScenarioModal.tsx @@ -1,5 +1,5 @@ import cx from 'classnames'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GoPencil, GoTrash } from 'react-icons/go'; import { FaPlus } from 'react-icons/fa'; @@ -25,6 +25,7 @@ import ModalHeaderSNCF from 'common/BootstrapSNCF/ModalSNCF/ModalHeaderSNCF'; import { ModalContext } from 'common/BootstrapSNCF/ModalSNCF/ModalProvider'; import { InfraSelectorModal } from 'modules/infra/components/InfraSelector'; import { setFailure, setSuccess } from 'reducers/main'; +import useModalFocusTrap from 'utils/hooks/useModalFocusTrap'; type CreateOrPatchScenarioForm = ScenarioPatchForm & { id?: number; @@ -86,6 +87,7 @@ export default function AddOrEditScenarioModal({ const dispatch = useDispatch(); const navigate = useNavigate(); const infraID = useInfraID(); + const modalRef = useRef(null); const selectedValue = useMemo(() => { if (currentScenario.electrical_profile_set_id) { @@ -206,8 +208,10 @@ export default function AddOrEditScenarioModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [infraID]); + useModalFocusTrap(modalRef, closeModal); + return ( -
+

{editionMode ? t('scenarioModificationTitle') : t('scenarioCreationTitle')} @@ -221,6 +225,7 @@ export default function AddOrEditScenarioModal({ id="scenarioInputName" type="text" name="scenarioInputName" + focus label={
diff --git a/front/src/modules/study/components/AddOrEditStudyModal.tsx b/front/src/modules/study/components/AddOrEditStudyModal.tsx index 67cdab15a2b..8c664d4f2dd 100644 --- a/front/src/modules/study/components/AddOrEditStudyModal.tsx +++ b/front/src/modules/study/components/AddOrEditStudyModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; @@ -28,6 +28,7 @@ import ModalHeaderSNCF from 'common/BootstrapSNCF/ModalSNCF/ModalHeaderSNCF'; import type { StudyCreateForm } from 'common/api/osrdEditoastApi'; import { setFailure, setSuccess } from 'reducers/main'; +import useModalFocusTrap from 'utils/hooks/useModalFocusTrap'; export interface StudyForm extends StudyCreateForm { id?: number; @@ -65,6 +66,8 @@ export default function AddOrEditStudyModal({ editionMode, study }: Props) { const studyCategoriesOptions = createSelectOptions('studyCategories', studyTypes); + const modalRef = useRef(null); + const removeTag = (idx: number) => { const newTags = [...(currentStudy.tags || [])]; newTags.splice(idx, 1); @@ -158,8 +161,10 @@ export default function AddOrEditStudyModal({ editionMode, study }: Props) { }; }, [currentStudy?.start_date, currentStudy?.expected_end_date, currentStudy?.actual_end_date]); + useModalFocusTrap(modalRef, closeModal); + return ( -
+

Study Logo @@ -172,6 +177,7 @@ export default function AddOrEditStudyModal({ editionMode, study }: Props) { id="studyInputName" type="text" name="studyInputName" + focus label={
diff --git a/front/src/utils/hooks/useModalFocusTrap.ts b/front/src/utils/hooks/useModalFocusTrap.ts new file mode 100644 index 00000000000..af715516a6f --- /dev/null +++ b/front/src/utils/hooks/useModalFocusTrap.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +/** + * Allow the user to escape the modal by pressing escape and to trap the focus inside it + * */ + +export default function useModalFocusTrap( + modalRef: React.RefObject, + closeModal: () => void +) { + useEffect(() => { + const modalElement = modalRef.current; + + const focusableElements = modalElement?.querySelectorAll( + // last declaration stands for all elements not natively focusable like li + 'input, button, [tabindex]:not([tabindex="-1"])' + ) as NodeListOf; + + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[focusableElements?.length - 1] as HTMLElement; + + /** + * + * Prevent the tab event and set focus on : + * - last element if we are pressing on "shift" in addition to "tab" and are on the first element + * - first element if we are only pressing "tab" and are on the last element + */ + const handleTabKeyPress = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }; + + const handleEscapeKeyPress: (event: KeyboardEvent) => void = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeModal(); + } + }; + + modalElement?.addEventListener('keydown', handleTabKeyPress); + modalElement?.addEventListener('keydown', handleEscapeKeyPress); + + return () => { + modalElement?.removeEventListener('keydown', handleTabKeyPress); + modalElement?.removeEventListener('keydown', handleEscapeKeyPress); + }; + }, [modalRef, closeModal]); +}