Skip to content

Commit 8ad904c

Browse files
committed
fix(ChapterMap): enforce locked map state with explicit unlock
1 parent 204f833 commit 8ad904c

2 files changed

Lines changed: 154 additions & 74 deletions

File tree

frontend/__tests__/unit/components/ChapterMap.test.tsx

Lines changed: 117 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,30 @@ const mockMap = {
1111
getCenter: jest.fn(() => ({ lat: 20, lng: 0 })),
1212
getZoom: jest.fn(() => 2),
1313
getContainer: jest.fn(() => document.getElementById('chapter-map')),
14+
dragging: {
15+
enable: jest.fn(),
16+
disable: jest.fn(),
17+
},
18+
touchZoom: {
19+
enable: jest.fn(),
20+
disable: jest.fn(),
21+
},
22+
doubleClickZoom: {
23+
enable: jest.fn(),
24+
disable: jest.fn(),
25+
},
1426
scrollWheelZoom: {
1527
enable: jest.fn(),
1628
disable: jest.fn(),
1729
},
30+
boxZoom: {
31+
enable: jest.fn(),
32+
disable: jest.fn(),
33+
},
34+
keyboard: {
35+
enable: jest.fn(),
36+
disable: jest.fn(),
37+
},
1838
}
1939

2040
const mockMarker = {
@@ -160,16 +180,20 @@ describe('ChapterMap', () => {
160180
expect(mockTileLayer.addTo).toHaveBeenCalledWith(mockMap)
161181
})
162182

163-
it('creates and adds marker cluster group', () => {
183+
it('disables all interaction handlers on initialization', () => {
164184
render(<ChapterMap {...defaultProps} />)
165-
expect(L.markerClusterGroup).toHaveBeenCalled()
166-
expect(mockMap.addLayer).toHaveBeenCalledWith(mockMarkerClusterGroup)
185+
expect(mockMap.dragging.disable).toHaveBeenCalled()
186+
expect(mockMap.touchZoom.disable).toHaveBeenCalled()
187+
expect(mockMap.doubleClickZoom.disable).toHaveBeenCalled()
188+
expect(mockMap.scrollWheelZoom.disable).toHaveBeenCalled()
189+
expect(mockMap.boxZoom.disable).toHaveBeenCalled()
190+
expect(mockMap.keyboard.disable).toHaveBeenCalled()
167191
})
168192

169-
it('sets up event listeners for map interaction', () => {
193+
it('creates and adds marker cluster group', () => {
170194
render(<ChapterMap {...defaultProps} />)
171-
expect(mockMap.on).toHaveBeenCalledWith('click', expect.any(Function))
172-
expect(mockMap.on).toHaveBeenCalledWith('mouseout', expect.any(Function))
195+
expect(L.markerClusterGroup).toHaveBeenCalled()
196+
expect(mockMap.addLayer).toHaveBeenCalledWith(mockMarkerClusterGroup)
173197
})
174198
})
175199

@@ -283,48 +307,35 @@ describe('ChapterMap', () => {
283307
expect(getByText('Unlock map')).toBeInTheDocument()
284308
})
285309

286-
it('removes overlay when clicked', () => {
310+
it('removes overlay when unlock button is clicked', () => {
287311
const { getByText, queryByText } = render(<ChapterMap {...defaultProps} />)
288312

289-
const overlay = getByText('Unlock map').closest('button')
290-
fireEvent.click(overlay!)
313+
const button = getByText('Unlock map').closest('button')
314+
fireEvent.click(button!)
291315

292316
expect(queryByText('Unlock map')).not.toBeInTheDocument()
293317
})
294318

295-
it('enables scroll wheel zoom when overlay is clicked', () => {
296-
const { getByText } = render(<ChapterMap {...defaultProps} />)
297-
298-
const overlay = getByText('Unlock map').closest('button')
299-
fireEvent.click(overlay!)
300-
301-
expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled()
302-
})
303-
304-
it('handles keyboard interaction with Enter key', () => {
319+
it('enables all interaction handlers when unlock button is clicked', () => {
305320
const { getByText } = render(<ChapterMap {...defaultProps} />)
306321

307-
const overlay = getByText('Unlock map').closest('button')
308-
fireEvent.keyDown(overlay!, { key: 'Enter' })
309-
310-
expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled()
311-
})
312-
313-
it('handles keyboard interaction with Space key', () => {
314-
const { getByText } = render(<ChapterMap {...defaultProps} />)
315-
316-
const overlay = getByText('Unlock map').closest('button')
317-
fireEvent.keyDown(overlay!, { key: ' ' })
322+
const button = getByText('Unlock map').closest('button')
323+
fireEvent.click(button!)
318324

325+
expect(mockMap.dragging.enable).toHaveBeenCalled()
326+
expect(mockMap.touchZoom.enable).toHaveBeenCalled()
327+
expect(mockMap.doubleClickZoom.enable).toHaveBeenCalled()
319328
expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled()
329+
expect(mockMap.boxZoom.enable).toHaveBeenCalled()
330+
expect(mockMap.keyboard.enable).toHaveBeenCalled()
320331
})
321332

322333
it('has proper accessibility attributes', () => {
323334
const { getByText } = render(<ChapterMap {...defaultProps} />)
324335

325-
const overlay = getByText('Unlock map').closest('button')
326-
expect(overlay).toHaveAttribute('tabIndex', '0')
327-
expect(overlay).toHaveAttribute('aria-label', 'Unlock map')
336+
const button = getByText('Unlock map').closest('button')
337+
expect(button).toHaveAttribute('type', 'button')
338+
expect(button).toHaveAttribute('aria-label', 'Unlock map')
328339
})
329340
})
330341

@@ -427,8 +438,8 @@ describe('ChapterMap', () => {
427438
it('shows zoom control when unlock button is clicked', () => {
428439
const { getByText } = render(<ChapterMap {...defaultProps} />)
429440

430-
const overlay = getByText('Unlock map').closest('button')
431-
fireEvent.click(overlay)
441+
const button = getByText('Unlock map').closest('button')
442+
fireEvent.click(button!)
432443

433444
expect(L.control.zoom).toHaveBeenCalledWith({ position: 'topleft' })
434445
expect(mockZoomControl.addTo).toHaveBeenCalledWith(mockMap)
@@ -468,4 +479,75 @@ describe('ChapterMap', () => {
468479
expect(queryByLabelText(/share location/i)).not.toBeInTheDocument()
469480
})
470481
})
482+
483+
describe('Escape Key Re-lock', () => {
484+
it('re-locks the map when Escape key is pressed', () => {
485+
const { getByText, queryByText } = render(<ChapterMap {...defaultProps} />)
486+
487+
// First unlock the map
488+
const unlockButton = getByText('Unlock map')
489+
fireEvent.click(unlockButton)
490+
491+
// Verify map is unlocked
492+
expect(queryByText('Unlock map')).not.toBeInTheDocument()
493+
expect(mockMap.dragging.enable).toHaveBeenCalled()
494+
495+
// Press Escape to re-lock
496+
fireEvent.keyDown(window, { key: 'Escape' })
497+
498+
// Verify map is locked again
499+
expect(getByText('Unlock map')).toBeInTheDocument()
500+
expect(mockMap.dragging.disable).toHaveBeenCalled()
501+
expect(mockMap.scrollWheelZoom.disable).toHaveBeenCalled()
502+
})
503+
504+
it('does nothing when Escape is pressed and map is already locked', () => {
505+
const { getByText } = render(<ChapterMap {...defaultProps} />)
506+
507+
const disableCallsBefore = mockMap.dragging.disable.mock.calls.length
508+
509+
// Press Escape while map is locked
510+
fireEvent.keyDown(window, { key: 'Escape' })
511+
512+
// Should still show unlock button
513+
expect(getByText('Unlock map')).toBeInTheDocument()
514+
515+
// Disable should not be called again
516+
expect(mockMap.dragging.disable.mock.calls.length).toBe(disableCallsBefore)
517+
})
518+
519+
it('removes zoom control when Escape re-locks the map', () => {
520+
const { getByText } = render(<ChapterMap {...defaultProps} />)
521+
522+
// Unlock the map
523+
const unlockButton = getByText('Unlock map')
524+
fireEvent.click(unlockButton)
525+
526+
// Zoom control should be added
527+
expect(mockZoomControl.addTo).toHaveBeenCalled()
528+
529+
// Press Escape to re-lock
530+
fireEvent.keyDown(window, { key: 'Escape' })
531+
532+
// Zoom control should be removed
533+
expect(mockZoomControl.remove).toHaveBeenCalled()
534+
})
535+
})
536+
537+
describe('Pointer Events Structure', () => {
538+
it('overlay wrapper has pointer-events-none class', () => {
539+
const { container } = render(<ChapterMap {...defaultProps} />)
540+
541+
const overlay = container.querySelector('.pointer-events-none')
542+
expect(overlay).toBeInTheDocument()
543+
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-[500]')
544+
})
545+
546+
it('unlock button has pointer-events-auto class', () => {
547+
const { getByText } = render(<ChapterMap {...defaultProps} />)
548+
549+
const button = getByText('Unlock map')
550+
expect(button).toHaveClass('pointer-events-auto')
551+
})
552+
})
471553
})

frontend/src/components/ChapterMap.tsx

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ const ChapterMap = ({
3333
const initialViewRef = useRef<{ center: L.LatLngExpression; zoom: number } | null>(null)
3434
const [isMapActive, setIsMapActive] = useState(false)
3535

36+
const setMapInteractivity = (map: L.Map, enabled: boolean) => {
37+
const action = enabled ? 'enable' : 'disable'
38+
map.dragging[action]()
39+
map.touchZoom[action]()
40+
map.doubleClickZoom[action]()
41+
map.scrollWheelZoom[action]()
42+
map.boxZoom[action]()
43+
map.keyboard[action]()
44+
}
45+
46+
const unlockMap = () => {
47+
if (!mapRef.current) return
48+
setMapInteractivity(mapRef.current, true)
49+
setIsMapActive(true)
50+
}
51+
3652
useEffect(() => {
3753
if (!mapRef.current) {
3854
mapRef.current = L.map('chapter-map', {
@@ -56,25 +72,7 @@ const ChapterMap = ({
5672
className: 'map-tiles',
5773
}).addTo(mapRef.current)
5874

59-
mapRef.current.on('click', () => {
60-
mapRef.current?.scrollWheelZoom.enable()
61-
setIsMapActive(true)
62-
})
63-
64-
mapRef.current.on('mouseout', (e: L.LeafletMouseEvent) => {
65-
const originalEvent = e.originalEvent as MouseEvent
66-
const relatedTarget = originalEvent.relatedTarget as Node | null
67-
const container = mapRef.current?.getContainer()
68-
const mapParent = container?.parentElement
69-
if (
70-
relatedTarget &&
71-
(container?.contains(relatedTarget) || mapParent?.contains(relatedTarget))
72-
)
73-
return
74-
75-
mapRef.current?.scrollWheelZoom.disable()
76-
setIsMapActive(false)
77-
})
75+
setMapInteractivity(mapRef.current, false)
7876
}
7977

8078
const map = mapRef.current
@@ -194,6 +192,17 @@ const ChapterMap = ({
194192
}
195193
}, [geoLocData, showLocal, userLocation])
196194

195+
useEffect(() => {
196+
const handleEscape = (e: KeyboardEvent) => {
197+
if (e.key === 'Escape' && isMapActive && mapRef.current) {
198+
setMapInteractivity(mapRef.current, false)
199+
setIsMapActive(false)
200+
}
201+
}
202+
window.addEventListener('keydown', handleEscape)
203+
return () => window.removeEventListener('keydown', handleEscape)
204+
}, [isMapActive])
205+
197206
useEffect(() => {
198207
const map = mapRef.current
199208
if (!map) return
@@ -220,28 +229,17 @@ const ChapterMap = ({
220229
<div className="relative" style={style}>
221230
<div id="chapter-map" className="h-full w-full" />
222231
{!isMapActive && (
223-
<button
224-
type="button"
225-
tabIndex={0}
226-
className="pointer-events-none absolute inset-0 z-[500] flex cursor-pointer items-center justify-center rounded-[inherit] bg-black/10"
227-
onClick={() => {
228-
mapRef.current?.scrollWheelZoom.enable()
229-
setIsMapActive(true)
230-
}}
231-
onKeyDown={(e) => {
232-
if (e.key === 'Enter' || e.key === ' ') {
233-
e.preventDefault()
234-
mapRef.current?.scrollWheelZoom.enable()
235-
setIsMapActive(true)
236-
}
237-
}}
238-
aria-label="Unlock map"
239-
>
240-
<p className="pointer-events-auto flex items-center gap-2 rounded-md bg-white/90 px-5 py-3 text-sm font-medium text-gray-700 shadow-lg transition-colors hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:hover:text-white">
232+
<div className="pointer-events-none absolute inset-0 z-[500] flex items-center justify-center bg-black/10">
233+
<button
234+
type="button"
235+
className="pointer-events-auto flex items-center gap-2 rounded-md bg-white/90 px-5 py-3 text-sm font-medium text-gray-700 shadow-lg transition-colors hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:hover:text-white"
236+
onClick={unlockMap}
237+
aria-label="Unlock map"
238+
>
241239
<FaUnlock aria-hidden="true" />
242240
Unlock map
243-
</p>
244-
</button>
241+
</button>
242+
</div>
245243
)}
246244
{isMapActive && (
247245
<div className="absolute top-20 left-3 z-[999] w-fit">

0 commit comments

Comments
 (0)