From e04cf85bef3dbe42f1587ffa9c376e3e927bd534 Mon Sep 17 00:00:00 2001 From: Azalea Date: Thu, 25 Jun 2026 20:16:15 +0000 Subject: [PATCH] feat(planner): seek places sidebar on map selection --- .../components/Planner/PlacesSidebar.test.tsx | 34 +++++++++++++++++++ .../components/Planner/PlacesSidebarList.tsx | 3 +- .../components/Planner/PlacesSidebarRow.tsx | 6 +++- .../components/Planner/usePlacesSidebar.ts | 26 +++++++++++++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index adb2881d..9c108911 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -124,6 +124,40 @@ describe('PlacesSidebar', () => { expect(screen.getByText('Central Park')).toBeInTheDocument(); }); + it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + scrollIntoView.mockClear(); + const places = [ + buildPlace({ id: 10, name: 'First Place' }), + buildPlace({ id: 42, name: 'Map Click Target' }), + ]; + + render(); + + const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]'); + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + }); + + it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => { + const user = userEvent.setup(); + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + const places = [ + buildPlace({ id: 10, name: 'Visible Cafe' }), + buildPlace({ id: 42, name: 'Hidden Museum' }), + ]; + const { rerender } = render(); + + await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible'); + scrollIntoView.mockClear(); + rerender(); + + expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + it('FE-COMP-PLACES-010: shows place count', () => { const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })]; render(); diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx index 631eab63..40da9ef4 100644 --- a/client/src/components/Planner/PlacesSidebarList.tsx +++ b/client/src/components/Planner/PlacesSidebarList.tsx @@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) { const { filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace, categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId, - isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, + isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, } = S return (
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> @@ -44,6 +44,7 @@ export function PlacesList(S: SidebarState) { onAssignToDay={onAssignToDay} toggleSelected={toggleSelected} setDayPickerPlace={setDayPickerPlace} + registerPlaceRow={registerPlaceRow} /> ) }) diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx index 14131282..6b7388a2 100644 --- a/client/src/components/Planner/PlacesSidebarRow.tsx +++ b/client/src/components/Planner/PlacesSidebarRow.tsx @@ -21,17 +21,21 @@ interface MemoPlaceRowProps { onAssignToDay: (placeId: number, dayId?: number) => void toggleSelected: (id: number) => void setDayPickerPlace: (place: any) => void + registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void } export const MemoPlaceRow = React.memo(function MemoPlaceRow({ place, category: cat, isSelected, isPlanned, inDay, isChecked, selectMode, selectedDayId, canEditPlaces, isMobile, t, - onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, + onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, }: MemoPlaceRowProps) { const hasGeometry = Boolean(place.route_geometry) return (
registerPlaceRow(place.id, element)} + aria-selected={isSelected} + data-place-id={place.id} draggable={!selectMode} onDragStart={e => { e.dataTransfer.setData('placeId', String(place.id)) diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 8518702c..3e07cd39 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -59,6 +59,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const [sidebarDragOver, setSidebarDragOver] = useState(false) const sidebarDragCounter = useRef(0) const scrollContainerRef = useRef(null) + const placeRowRefs = useRef(new Map()) + const lastAutoScrolledPlaceIdRef = useRef(null) useLayoutEffect(() => { if (scrollContainerRef.current && initialScrollTop) { scrollContainerRef.current.scrollTop = initialScrollTop @@ -197,6 +199,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { return true }), [places, filter, categoryFilters, search, plannedIds]) + const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => { + if (element) { + placeRowRefs.current.set(placeId, element) + } else { + placeRowRefs.current.delete(placeId) + } + }, []) + + useEffect(() => { + if (!props.selectedPlaceId) { + lastAutoScrolledPlaceIdRef.current = null + return + } + if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return + if (!filtered.some(place => place.id === props.selectedPlaceId)) return + + const selectedRow = placeRowRefs.current.get(props.selectedPlaceId) + if (!selectedRow) return + selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' }) + lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId + }, [filtered, props.selectedPlaceId]) + const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) @@ -234,7 +258,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, - hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu, + hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu, } }