diff --git a/front/package-lock.json b/front/package-lock.json index 0e34b12c392..a57cea88367 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -13,12 +13,12 @@ "@nivo/line": "^0.80.0", "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", "@osrd-project/netzgrafik-frontend": "0.0.0-snapshot.37949a66933e8e1552c9b8e54f702ec491afd415", - "@osrd-project/ui-core": "^0.0.56", - "@osrd-project/ui-icons": "^0.0.56", - "@osrd-project/ui-manchette": "^0.0.56", - "@osrd-project/ui-manchette-with-spacetimechart": "^0.0.56", - "@osrd-project/ui-spacetimechart": "^0.0.56", - "@osrd-project/ui-speedspacechart": "^0.0.56", + "@osrd-project/ui-core": "^0.0.57", + "@osrd-project/ui-icons": "^0.0.57", + "@osrd-project/ui-manchette": "^0.0.57", + "@osrd-project/ui-manchette-with-spacetimechart": "^0.0.57", + "@osrd-project/ui-spacetimechart": "^0.0.57", + "@osrd-project/ui-speedspacechart": "^0.0.57", "@react-pdf/renderer": "^3.4.2", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "^2.1.0", @@ -1924,9 +1924,9 @@ "integrity": "sha512-lalH7CHnIQfaPLh+dYMLcpIYb7+MnCJWov7wnJ5DUTLTnt7WhrKR+Q+wXRpac4j1ehjEDdoeuy2KViAhVIMTKg==" }, "node_modules/@osrd-project/ui-core": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-core/-/ui-core-0.0.56.tgz", - "integrity": "sha512-sdHViimD/FcNM3GtHuUFmqoLLnaNIMsr5F2TNFAZ4t4yVKKP/fy/TPJDnkXFMYsg20gMfGwRgEqsyVnIiSqXYg==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-core/-/ui-core-0.0.57.tgz", + "integrity": "sha512-TPxr5fEezAh/8ronWK6L2yV6CBJIThrQkhZ4bGkRa1WMoSX71+dfVBa5zM0aMGSEhPYVW2pn3xKglI32PZz7Hw==", "license": "LGPL-3.0-or-later", "dependencies": { "classnames": "^2.5.1", @@ -1937,18 +1937,18 @@ } }, "node_modules/@osrd-project/ui-icons": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-icons/-/ui-icons-0.0.56.tgz", - "integrity": "sha512-vxok7+1qGpNIq5g7FWSt9uCtaLrC0JqS5QItlCZPRSTP9ltJ0ruCMBH4ecloHZNRguTi8JzOXG8bCljD7wQWQQ==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-icons/-/ui-icons-0.0.57.tgz", + "integrity": "sha512-BBVIldy6eBZ7euYsRK1QVbIvRAyMTlhnJcv3XQAo4M3TeH+zpKI0WRnenNUPbo5dFrHwtjwbQqcvDhroe2JaWQ==", "license": "LGPL-3.0-or-later", "peerDependencies": { "react": ">=18.0" } }, "node_modules/@osrd-project/ui-manchette": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-manchette/-/ui-manchette-0.0.56.tgz", - "integrity": "sha512-JnGOLMLd8DAB+Y5f+1h+DhhmJ61tZUB8xrYUeX0a631i/EM5uNRizGyIQkncbVz1WnPdA9dDfwMLJTl+7NmPrg==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-manchette/-/ui-manchette-0.0.57.tgz", + "integrity": "sha512-7U6ONFYxi+Gqtzut6RXmYcB0m9BSavn1aBq7oopHdY+4fsfgctyxWtrgrwq7e4Rd/sWsKfW5Ku1wlH+3W5Rk3A==", "license": "LGPL-3.0-or-later", "dependencies": { "classnames": "^2.5.1", @@ -1959,13 +1959,13 @@ } }, "node_modules/@osrd-project/ui-manchette-with-spacetimechart": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-manchette-with-spacetimechart/-/ui-manchette-with-spacetimechart-0.0.56.tgz", - "integrity": "sha512-HiGxeSD3fGJBi0tkCThYkafdym9XoRN+2pgYBP7BndDlgyVwrKYuovO5+R2L3e1MU3Zxs6CVaip8Xf+MCpSWrg==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-manchette-with-spacetimechart/-/ui-manchette-with-spacetimechart-0.0.57.tgz", + "integrity": "sha512-acYdovcMpypbjJ4YbMpt0Tk1fKsS4BxPSJZskiY7SAuQO+9qPXXQEszuq412pzzMwbZxDH15uWWFfQAYoKXU/w==", "license": "LGPL-3.0-or-later", "dependencies": { - "@osrd-project/ui-manchette": "^0.0.56", - "@osrd-project/ui-spacetimechart": "^0.0.56", + "@osrd-project/ui-manchette": "^0.0.57", + "@osrd-project/ui-spacetimechart": "^0.0.57", "classnames": "^2.5.1", "lodash.isequal": "^4.5.0", "vitest": "^2.1.1" @@ -1975,9 +1975,9 @@ } }, "node_modules/@osrd-project/ui-spacetimechart": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-spacetimechart/-/ui-spacetimechart-0.0.56.tgz", - "integrity": "sha512-dLYlgl0coKdfqdlvE9evNTbdCl3kGv0lA2SNdPHR0+dlhoA1ehm5bpAu8fruVjJbv0Mq/8yzd6SoczaR7rbdiw==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-spacetimechart/-/ui-spacetimechart-0.0.57.tgz", + "integrity": "sha512-rNLZoZeIl4wqwfeFCIPn2e5FI//hpsg38Y1sAhbB4CG4v4kxmSNslTa/mfSs4GNXO9fZgT+9klTAffKKp4FC4g==", "license": "LGPL-3.0-or-later", "dependencies": { "@types/chroma-js": "^2.4.4", @@ -1993,13 +1993,13 @@ } }, "node_modules/@osrd-project/ui-speedspacechart": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/@osrd-project/ui-speedspacechart/-/ui-speedspacechart-0.0.56.tgz", - "integrity": "sha512-NHg1T+ZRVIphfY7iNaYbBclIK+FkhhFgUiXqMQ2RdNcIOZV07fxDe0H/kdWQravVIv00A6ribkJemlHuodN1wg==", + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@osrd-project/ui-speedspacechart/-/ui-speedspacechart-0.0.57.tgz", + "integrity": "sha512-4TzNi5JNEszDKM1/IWIpCAVyM099AjwYSCME0LosiVG2DJM07nBx6XpKEhbgI/gSCT6S2apo0l3ViVc2kjT34w==", "license": "LGPL-3.0-or-later", "dependencies": { - "@osrd-project/ui-core": "^0.0.56", - "@osrd-project/ui-icons": "^0.0.56", + "@osrd-project/ui-core": "^0.0.57", + "@osrd-project/ui-icons": "^0.0.57", "@types/chroma-js": "^2.4.4", "@types/d3-selection": "^3.0.0", "@types/d3-zoom": "^3.0.0", diff --git a/front/package.json b/front/package.json index f07f22a3569..ef668d779fe 100644 --- a/front/package.json +++ b/front/package.json @@ -8,12 +8,12 @@ "@nivo/line": "^0.80.0", "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", "@osrd-project/netzgrafik-frontend": "0.0.0-snapshot.37949a66933e8e1552c9b8e54f702ec491afd415", - "@osrd-project/ui-core": "^0.0.56", - "@osrd-project/ui-icons": "^0.0.56", - "@osrd-project/ui-manchette": "^0.0.56", - "@osrd-project/ui-manchette-with-spacetimechart": "^0.0.56", - "@osrd-project/ui-spacetimechart": "^0.0.56", - "@osrd-project/ui-speedspacechart": "^0.0.56", + "@osrd-project/ui-core": "^0.0.57", + "@osrd-project/ui-icons": "^0.0.57", + "@osrd-project/ui-manchette": "^0.0.57", + "@osrd-project/ui-manchette-with-spacetimechart": "^0.0.57", + "@osrd-project/ui-spacetimechart": "^0.0.57", + "@osrd-project/ui-speedspacechart": "^0.0.57", "@react-pdf/renderer": "^3.4.2", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "^2.1.0", diff --git a/front/public/locales/en/simulation.json b/front/public/locales/en/simulation.json index 0dfb167eae6..4e974d9680f 100644 --- a/front/public/locales/en/simulation.json +++ b/front/public/locales/en/simulation.json @@ -88,6 +88,9 @@ }, "trainList": "Train list", "waiting": "Loading...", + "waypointMenu": { + "hide": "Hide this OP" + }, "waypointsPanel": { "name": "name", "secondaryCode": "CH", diff --git a/front/public/locales/fr/simulation.json b/front/public/locales/fr/simulation.json index ce7146ae8c2..aef02c810c7 100644 --- a/front/public/locales/fr/simulation.json +++ b/front/public/locales/fr/simulation.json @@ -88,6 +88,9 @@ }, "trainList": "Liste des trains", "waiting": "Chargement en cours…", + "waypointMenu": { + "hide": "Masquer ce PR" + }, "waypointsPanel": { "name": "nom", "secondaryCode": "CH", diff --git a/front/src/common/OSRDMenu.tsx b/front/src/common/OSRDMenu.tsx index ca0ae9f2fb9..ac1ef50e309 100644 --- a/front/src/common/OSRDMenu.tsx +++ b/front/src/common/OSRDMenu.tsx @@ -4,6 +4,8 @@ export type OSRDMenuItem = { title: string; icon: React.ReactNode; onClick: () => void; + disabled?: boolean; + disabledMessage?: string; }; type OSRDMenuProps = { @@ -13,8 +15,15 @@ type OSRDMenuProps = { const OSRDMenu = ({ menuRef, items }: OSRDMenuProps) => (
- {items.map(({ title, icon, onClick }) => ( - diff --git a/front/src/modules/simulationResult/components/ManchetteWithSpaceTimeChart/ManchetteWithSpaceTimeChart.tsx b/front/src/modules/simulationResult/components/ManchetteWithSpaceTimeChart/ManchetteWithSpaceTimeChart.tsx index a3066a8a955..c5c81a38fa1 100644 --- a/front/src/modules/simulationResult/components/ManchetteWithSpaceTimeChart/ManchetteWithSpaceTimeChart.tsx +++ b/front/src/modules/simulationResult/components/ManchetteWithSpaceTimeChart/ManchetteWithSpaceTimeChart.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import { KebabHorizontal } from '@osrd-project/ui-icons'; -import { Manchette } from '@osrd-project/ui-manchette'; +import Manchette, { type WaypointMenuData } from '@osrd-project/ui-manchette'; import { useManchettesWithSpaceTimeChart } from '@osrd-project/ui-manchette-with-spacetimechart'; import { ConflictLayer, @@ -11,11 +11,13 @@ import { OccupancyBlockLayer, } from '@osrd-project/ui-spacetimechart'; import type { Conflict } from '@osrd-project/ui-spacetimechart'; +import cx from 'classnames'; import { compact } from 'lodash'; import type { OperationalPoint, TrainSpaceTimeData } from 'applications/operationalStudies/types'; import upward from 'assets/pictures/workSchedules/ScheduledMaintenanceUp.svg'; import type { PostWorkSchedulesProjectPathApiResponse } from 'common/api/osrdEditoastApi'; +import OSRDMenu from 'common/OSRDMenu'; import cutSpaceTimeRect from 'modules/simulationResult/components/SpaceTimeChart/helpers/utils'; import { ASPECT_LABELS_COLORS } from 'modules/simulationResult/consts'; import type { @@ -27,6 +29,7 @@ import type { import SettingsPanel from './SettingsPanel'; import ManchetteMenuButton from '../SpaceTimeChart/ManchetteMenuButton'; import ProjectionLoadingMessage from '../SpaceTimeChart/ProjectionLoadingMessage'; +import useWaypointMenu from '../SpaceTimeChart/useWaypointMenu'; import WaypointsPanel from '../SpaceTimeChart/WaypointsPanel'; type ManchetteWithSpaceTimeChartProps = { @@ -55,6 +58,7 @@ const ManchetteWithSpaceTimeChartWrapper = ({ projectionLoaderData: { totalTrains, allTrainsProjected }, height = MANCHETTE_WITH_SPACE_TIME_CHART_DEFAULT_HEIGHT, }: ManchetteWithSpaceTimeChartProps) => { + const manchetteWithSpaceTimeCharWrappertRef = useRef(null); const manchetteWithSpaceTimeChartRef = useRef(null); const [waypointsPanelIsOpen, setWaypointsPanelIsOpen] = useState(false); @@ -178,8 +182,26 @@ const ManchetteWithSpaceTimeChartWrapper = ({ })); }); + const waypointMenuData = useWaypointMenu(waypointsPanelData); + + const manchettePropsWithWaypointMenu = useMemo( + () => ({ + ...manchetteProps, + waypoints: manchetteProps.waypoints.map((waypoint) => ({ + ...waypoint, + onClick: waypointMenuData.handleWaypointClick, + })), + waypointMenuData: { + menu: , + activeWaypointId: waypointMenuData.activeWaypointId, + manchetteWrapperRef: manchetteWithSpaceTimeCharWrappertRef, + } as WaypointMenuData, + }), + [manchetteProps, waypointMenuData] + ); + return ( -
+
{waypointsPanelData && ( <> @@ -204,11 +226,13 @@ const ManchetteWithSpaceTimeChartWrapper = ({
- +
{ // TODO : refacto useOutsideClick to accept a list of refs and use the hook here diff --git a/front/src/modules/simulationResult/components/SpaceTimeChart/useWaypointMenu.tsx b/front/src/modules/simulationResult/components/SpaceTimeChart/useWaypointMenu.tsx new file mode 100644 index 00000000000..7ec188e7b65 --- /dev/null +++ b/front/src/modules/simulationResult/components/SpaceTimeChart/useWaypointMenu.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from 'react'; + +import { EyeClosed } from '@osrd-project/ui-icons'; +import { omit } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { useOsrdConfSelectors } from 'common/osrdContext'; +import type { OSRDMenuItem } from 'common/OSRDMenu'; +import type { WaypointsPanelData } from 'modules/simulationResult/types'; +import useModalFocusTrap from 'utils/hooks/useModalFocusTrap'; + +const useWaypointMenu = (waypointsPanelData?: WaypointsPanelData) => { + const { filteredWaypoints, setFilteredWaypoints, projectionPath } = waypointsPanelData || {}; + const { t } = useTranslation('simulation'); + + const { getTimetableID } = useOsrdConfSelectors(); + const timetableId = useSelector(getTimetableID); + + const [activeWaypointId, setActiveWaypointId] = useState(); + const [isClickOnWaypoint, setIsClickOnWaypoint] = useState(false); + + const menuRef = useRef(null); + + const closeMenu = () => { + setActiveWaypointId(undefined); + }; + + useModalFocusTrap(menuRef, closeMenu, { focusOnFirstElement: true }); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + // Avoid closing the menu when clicking on another waypoint + if (activeWaypointId && (event.target as HTMLElement).closest('.waypoint')) { + setIsClickOnWaypoint(true); + } + // Close the menu if the user clicks outside of it + if (!menuRef.current?.contains(event.target as Node)) { + closeMenu(); + } + }; + + if (activeWaypointId) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [activeWaypointId]); + + const menuItems: OSRDMenuItem[] = [ + { + title: t('waypointMenu.hide'), + icon: , + disabled: filteredWaypoints ? filteredWaypoints.length <= 2 : false, + disabledMessage: t('waypointsPanel.warning'), + onClick: () => { + closeMenu(); + setFilteredWaypoints?.((prevFilteredWaypoints) => { + const newFilteredWaypoints = prevFilteredWaypoints.filter( + (waypoint) => waypoint.id !== activeWaypointId + ); + + // We need to removed the id because it can change for waypoints added by map click + const simplifiedPath = projectionPath?.map((waypoint) => + omit(waypoint, ['id', 'deleted']) + ); + + // TODO : when switching to the manchette back-end manager, remove all logic using + // cleanScenarioLocalStorage from projet/study/scenario components (single/multi select) + localStorage.setItem( + `${timetableId}-${JSON.stringify(simplifiedPath)}`, + JSON.stringify(newFilteredWaypoints) + ); + return newFilteredWaypoints; + }); + }, + }, + ]; + + const handleWaypointClick = (id: string) => { + // Avoid reopening the menu when clicking on another waypoint or on the same one + if (isClickOnWaypoint) { + setIsClickOnWaypoint(false); + return; + } + setActiveWaypointId(id); + }; + + return { menuRef, menuItems, activeWaypointId, handleWaypointClick }; +}; + +export default useWaypointMenu; diff --git a/front/src/modules/simulationResult/types.ts b/front/src/modules/simulationResult/types.ts index e655480bd3b..83df1523dda 100644 --- a/front/src/modules/simulationResult/types.ts +++ b/front/src/modules/simulationResult/types.ts @@ -1,3 +1,5 @@ +import type { Dispatch, SetStateAction } from 'react'; + import type { LayerData, PowerRestrictionValues, @@ -41,7 +43,7 @@ export type ProjectionData = { export type WaypointsPanelData = { filteredWaypoints: OperationalPoint[]; - setFilteredWaypoints: (waypoints: OperationalPoint[]) => void; + setFilteredWaypoints: Dispatch>; projectionPath: TrainScheduleBase['path']; }; diff --git a/front/src/styles/scss/common/components/_manchetteWithSpaceTimeChart.scss b/front/src/styles/scss/common/components/_manchetteWithSpaceTimeChart.scss index 6fc87147923..0be5e01d7ad 100644 --- a/front/src/styles/scss/common/components/_manchetteWithSpaceTimeChart.scss +++ b/front/src/styles/scss/common/components/_manchetteWithSpaceTimeChart.scss @@ -4,6 +4,7 @@ 0 4px 9px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.19); background-color: var(--white100); + position: relative; .header { position: relative; @@ -37,6 +38,15 @@ .manchette { overflow-y: auto; overflow-x: hidden; + + &.no-scroll { + overflow-y: clip; + } + + .osrd-menu { + width: 305px; + transform: translateY(-2px); + } } .space-time-chart-container { diff --git a/front/src/styles/scss/common/components/_osrdMenu.scss b/front/src/styles/scss/common/components/_osrdMenu.scss index 5efa4607715..c08990521ab 100644 --- a/front/src/styles/scss/common/components/_osrdMenu.scss +++ b/front/src/styles/scss/common/components/_osrdMenu.scss @@ -12,6 +12,10 @@ 0 3px 5px -2px rgba(0, 0, 0, 0.1), inset 0 0 0 1px var(--white100); + button:disabled { + opacity: 0.5; + } + .menu-item { width: 100%; height: 44px; diff --git a/front/src/utils/hooks/useModalFocusTrap.ts b/front/src/utils/hooks/useModalFocusTrap.ts index 52c29f35dca..0f936375608 100644 --- a/front/src/utils/hooks/useModalFocusTrap.ts +++ b/front/src/utils/hooks/useModalFocusTrap.ts @@ -6,7 +6,8 @@ import { useEffect } from 'react'; export default function useModalFocusTrap( modalRef: React.RefObject, - closeModal: () => void + closeModal: () => void, + { focusOnFirstElement = false } = {} ) { useEffect(() => { const modalElement = modalRef.current; @@ -16,8 +17,10 @@ export default function useModalFocusTrap( 'input, button, [tabindex]:not([tabindex="-1"])' ); - const firstElement = focusableElements?.[0]; - const lastElement = focusableElements?.[focusableElements?.length - 1]; + const firstElement = focusableElements?.[0] as HTMLElement; + const lastElement = focusableElements?.[focusableElements?.length - 1] as HTMLElement; + + if (focusOnFirstElement) firstElement?.focus(); /** * @@ -30,10 +33,10 @@ export default function useModalFocusTrap( if (keyboardEvent.key === 'Tab') { if (keyboardEvent.shiftKey && document.activeElement === firstElement) { keyboardEvent.preventDefault(); - (lastElement as HTMLElement).focus(); + lastElement.focus(); } else if (!keyboardEvent.shiftKey && document.activeElement === lastElement) { event.preventDefault(); - (firstElement as HTMLElement).focus(); + firstElement.focus(); } } }; @@ -45,12 +48,12 @@ export default function useModalFocusTrap( } }; - modalElement?.addEventListener('keydown', handleTabKeyPress); - modalElement?.addEventListener('keydown', handleEscapeKeyPress); + document.addEventListener('keydown', handleTabKeyPress); + document.addEventListener('keydown', handleEscapeKeyPress); return () => { - modalElement?.removeEventListener('keydown', handleTabKeyPress); - modalElement?.removeEventListener('keydown', handleEscapeKeyPress); + document.removeEventListener('keydown', handleTabKeyPress); + document.removeEventListener('keydown', handleEscapeKeyPress); }; }, [modalRef, closeModal]); }