feat(planner): seek places sidebar on map selection

This commit is contained in:
Azalea
2026-06-25 20:16:15 +00:00
committed by Maurice
parent 3d65bb0c12
commit e04cf85bef
4 changed files with 66 additions and 3 deletions
@@ -124,6 +124,40 @@ describe('PlacesSidebar', () => {
expect(screen.getByText('Central Park')).toBeInTheDocument(); 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<typeof vi.fn>;
scrollIntoView.mockClear();
const places = [
buildPlace({ id: 10, name: 'First Place' }),
buildPlace({ id: 42, name: 'Map Click Target' }),
];
render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
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<typeof vi.fn>;
const places = [
buildPlace({ id: 10, name: 'Visible Cafe' }),
buildPlace({ id: 42, name: 'Hidden Museum' }),
];
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
scrollIntoView.mockClear();
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument();
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('FE-COMP-PLACES-010: shows place count', () => { it('FE-COMP-PLACES-010: shows place count', () => {
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })]; const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
render(<PlacesSidebar {...defaultProps} places={places} />); render(<PlacesSidebar {...defaultProps} places={places} />);
@@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) {
const { const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace, filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId, categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
} = S } = S
return ( return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
@@ -44,6 +44,7 @@ export function PlacesList(S: SidebarState) {
onAssignToDay={onAssignToDay} onAssignToDay={onAssignToDay}
toggleSelected={toggleSelected} toggleSelected={toggleSelected}
setDayPickerPlace={setDayPickerPlace} setDayPickerPlace={setDayPickerPlace}
registerPlaceRow={registerPlaceRow}
/> />
) )
}) })
@@ -21,17 +21,21 @@ interface MemoPlaceRowProps {
onAssignToDay: (placeId: number, dayId?: number) => void onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void setDayPickerPlace: (place: any) => void
registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void
} }
export const MemoPlaceRow = React.memo(function MemoPlaceRow({ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked, place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t, selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) { }: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry) const hasGeometry = Boolean(place.route_geometry)
return ( return (
<div <div
key={place.id} key={place.id}
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode} draggable={!selectMode}
onDragStart={e => { onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.setData('placeId', String(place.id))
@@ -59,6 +59,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [sidebarDragOver, setSidebarDragOver] = useState(false) const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0) const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null) const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) { if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop scrollContainerRef.current.scrollTop = initialScrollTop
@@ -197,6 +199,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true return true
}), [places, filter, categoryFilters, search, plannedIds]) }), [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) => const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === 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, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu, hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
} }
} }