Skip to content

Commit dca4b4e

Browse files
committed
replace expand button
1 parent 0f43534 commit dca4b4e

5 files changed

Lines changed: 129 additions & 184 deletions

File tree

src/hooks/useConstrainedFloatingButton.ts

Lines changed: 0 additions & 134 deletions
This file was deleted.

src/pages/components/map-related/MapShell.tsx

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,45 @@
11
import { CloseFullscreenTwoTone, OpenInFullTwoTone } from '@mui/icons-material'
22
import { IconButton } from '@mui/material'
3-
import { PropsWithChildren, ReactNode, useCallback, useRef, useState } from 'react'
3+
import { PropsWithChildren, ReactNode, useCallback, useState } from 'react'
44
import { useTranslation } from 'react-i18next'
5-
import { AttributionControl, MapContainer, MapContainerProps, ZoomControl } from 'react-leaflet'
6-
import { useConstrainedFloatingButton } from 'src/hooks/useConstrainedFloatingButton'
5+
import { AttributionControl, MapContainer, MapContainerProps } from 'react-leaflet'
76
import { useTheme } from 'src/layout/ThemeContext'
7+
import { MapZoomBar } from './MapZoomBar'
88

99
/**
1010
* Shared shell for every map page: the `.map-info` wrapper (with the
11-
* expand/collapse + dark-theme classes), the floating expand button, and a
12-
* MapContainer whose zoom/attribution controls are placed direction-aware
13-
* (zoom at the inline-end, attribution at the inline-start). Pages pass the
14-
* MapContainer props (center/zoom/…) and the layers as children. An optional
15-
* `legend` renders in the shared `.map-legend` box (top inline-start corner)
16-
* so every map page places and themes its legend the same way — pages provide
17-
* only the legend content.
11+
* expand/collapse + dark-theme classes) and a MapContainer whose controls are
12+
* placed direction-aware — the zoom bar at the inline-end with the
13+
* expand/collapse button as its bottom segment, attribution at the
14+
* inline-start. Pages pass the MapContainer props (center/zoom/…) and the
15+
* layers as children. An optional `legend` renders in the shared `.map-legend`
16+
* box (top inline-start corner) so every map page places and themes its legend
17+
* the same way — pages provide only the legend content.
1818
*/
1919
type MapShellProps = PropsWithChildren<MapContainerProps & { legend?: ReactNode }>
2020

2121
export function MapShell({ children, legend, ...mapProps }: MapShellProps) {
2222
const [isExpanded, setIsExpanded] = useState<boolean>(false)
2323
const toggleExpanded = useCallback(() => setIsExpanded((expanded) => !expanded), [])
2424

25-
const mapContainerRef = useRef<HTMLDivElement>(null)
26-
const buttonRef = useRef<HTMLButtonElement>(null)
27-
useConstrainedFloatingButton(mapContainerRef, buttonRef, isExpanded)
28-
2925
const { isDarkTheme } = useTheme()
3026
const { i18n } = useTranslation()
3127
const isRtl = i18n.dir() === 'rtl'
3228

3329
return (
3430
<div
35-
ref={mapContainerRef}
3631
className={`map-info ${isExpanded ? 'expanded' : 'collapsed'}${isDarkTheme ? ' dark' : ''}`}>
37-
<IconButton
38-
ref={buttonRef}
39-
color="primary"
40-
className="expand-button"
41-
onClick={toggleExpanded}>
42-
{isExpanded ? (
43-
<CloseFullscreenTwoTone fontSize="large" />
44-
) : (
45-
<OpenInFullTwoTone fontSize="large" />
46-
)}
47-
</IconButton>
4832
{legend && <div className="map-legend">{legend}</div>}
4933
<MapContainer {...mapProps} zoomControl={false} attributionControl={false}>
50-
<ZoomControl position={isRtl ? 'topleft' : 'topright'} />
34+
<MapZoomBar position={isRtl ? 'topleft' : 'topright'}>
35+
<IconButton className="expand-button" onClick={toggleExpanded}>
36+
{isExpanded ? (
37+
<CloseFullscreenTwoTone fontSize="small" />
38+
) : (
39+
<OpenInFullTwoTone fontSize="small" />
40+
)}
41+
</IconButton>
42+
</MapZoomBar>
5143
<AttributionControl position={isRtl ? 'bottomright' : 'bottomleft'} prefix={false} />
5244
{children}
5345
</MapContainer>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Control, ControlPosition, DomEvent, DomUtil } from 'leaflet'
2+
import { PropsWithChildren, useEffect, useState } from 'react'
3+
import { createPortal } from 'react-dom'
4+
import { useMap } from 'react-leaflet'
5+
6+
type MapZoomBarProps = PropsWithChildren<{ position: ControlPosition }>
7+
8+
/**
9+
* Leaflet's zoom control with extra buttons appended into the same
10+
* `.leaflet-bar`, so they read as one connected control rather than separate
11+
* boxes stacked in a corner. Zoom in/out stays Leaflet's own: it keeps the
12+
* disabled states at the zoom limits, the button titles and the keyboard
13+
* handling that a hand-rolled pair of buttons would have to reimplement.
14+
*
15+
* The slot is a stable node appended after `addTo`, so the container Leaflet
16+
* builds is never queried or patched from the outside — changing `position`
17+
* just rebuilds the control and re-appends the same slot.
18+
*/
19+
export function MapZoomBar({ position, children }: MapZoomBarProps) {
20+
const map = useMap()
21+
const [slot] = useState(() => DomUtil.create('div', 'map-zoom-bar-slot'))
22+
23+
useEffect(() => {
24+
// Without this a click on the button also reaches the map and pans/zooms it.
25+
DomEvent.disableClickPropagation(slot)
26+
DomEvent.disableScrollPropagation(slot)
27+
}, [slot])
28+
29+
useEffect(() => {
30+
const control = new Control.Zoom({ position })
31+
control.addTo(map)
32+
control.getContainer()?.appendChild(slot)
33+
return () => {
34+
control.remove()
35+
}
36+
}, [map, position, slot])
37+
38+
return createPortal(children, slot)
39+
}

src/resources/map.scss

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ $button-shadow:
1919
$button-border: 1px solid rgb(0 0 0 / 25%);
2020
$button-border-dark: 1px solid rgb(255 255 255 / 25%);
2121

22+
// Divider between the segments of the zoom bar (+ / − / expand). Deliberately
23+
// stronger than the frame: Leaflet's own #ccc all but disappears against the
24+
// button background, which made the three segments read as one blank slab.
25+
$bar-divider: rgb(0 0 0 / 40%);
26+
$bar-divider-dark: rgb(255 255 255 / 40%);
27+
2228
main,
2329
main > div,
2430
.map-container,
@@ -38,41 +44,64 @@ main > div,
3844
position: relative;
3945
margin-bottom: 1em;
4046

41-
.expand-button {
42-
inset-inline-end: 5px;
43-
bottom: 5px;
44-
color: #1976d2 !important;
45-
position: fixed;
46-
z-index: 2000;
47-
width: 2.6rem;
48-
height: 2.6rem;
49-
background: $button-bg;
50-
border: $button-border;
51-
box-shadow: $button-shadow;
52-
53-
&:hover {
54-
background: rgb(255 255 255 / 95%);
55-
}
56-
57-
&:active {
58-
background: rgb(255 255 255 / 100%);
59-
}
47+
// The slot MapZoomBar appends into the zoom bar must not generate a box of
48+
// its own, or the button would be nested one level deeper than the zoom
49+
// links and stop lining up with them.
50+
.map-zoom-bar-slot {
51+
display: contents;
6052
}
6153

62-
// Match the expand button's frame so both buttons read with equal weight.
6354
.leaflet-control-zoom.leaflet-bar {
6455
border: $button-border;
6556
box-shadow: $button-shadow;
6657
}
6758

6859
.leaflet-control-zoom a {
6960
background: $button-bg;
61+
border-bottom-color: $bar-divider;
7062

7163
&:hover {
7264
background: rgb(255 255 255 / 95%);
7365
}
7466
}
7567

68+
// The expand button is the bar's bottom segment, so it drops every MUI
69+
// button default (round, padded, its own frame) and takes the zoom links'
70+
// box instead: same size, same background, square but for the bar's bottom
71+
// corners. The separator above it is the zoom-out link's own bottom border,
72+
// which Leaflet only removes while that link is the bar's :last-child.
73+
.leaflet-control-zoom .expand-button {
74+
display: flex;
75+
align-items: center;
76+
justify-content: center;
77+
width: 26px;
78+
height: 26px;
79+
padding: 0;
80+
border: none;
81+
border-radius: 0 0 4px 4px;
82+
background: $button-bg;
83+
color: #1976d2 !important;
84+
85+
&:hover {
86+
background: rgb(255 255 255 / 95%);
87+
}
88+
89+
&:active {
90+
background: rgb(255 255 255 / 100%);
91+
}
92+
93+
svg {
94+
font-size: 16px;
95+
}
96+
}
97+
98+
// Leaflet grows its bar buttons on touch-capable displays; track it so the
99+
// expand button never ends up a different size from the zoom links.
100+
.leaflet-touch .leaflet-control-zoom .expand-button {
101+
width: 30px;
102+
height: 30px;
103+
}
104+
76105
.leaflet-control-attribution {
77106
background: $control-bg;
78107
}
@@ -185,7 +214,7 @@ main > div,
185214
.leaflet-control-zoom a {
186215
background: $button-bg-dark;
187216
color: #fff;
188-
border-color: #444;
217+
border-bottom-color: $bar-divider-dark;
189218

190219
&:hover {
191220
background: rgb(40 40 40 / 100%);
@@ -201,9 +230,10 @@ main > div,
201230
}
202231
}
203232

204-
.expand-button {
233+
// Background only — the accent color, frame and divider are all inherited
234+
// from the light rules, which already hold in both themes.
235+
.leaflet-control-zoom .expand-button {
205236
background: $button-bg-dark;
206-
border: $button-border-dark;
207237

208238
&:hover {
209239
background: rgb(40 40 40 / 95%);

tests/mapExpand.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ test.beforeEach(async ({ page }) => {
55
await visitPage(page, 'time_based_map_page_title')
66
})
77

8+
// The button used to be viewport-`fixed` and dragged back over the map by a
9+
// scroll/resize hook, which let it drift outside the map whenever the hook
10+
// missed an event. It is now the bottom segment of Leaflet's zoom bar, so
11+
// containment is structural and it has to read as one control with the zoom
12+
// links rather than a separate floating box.
13+
test('the expand button is the bottom segment of the zoom bar', async ({ page }) => {
14+
const zoomOut = page.locator('.leaflet-control-zoom .leaflet-control-zoom-out')
15+
const expand = page.locator('.leaflet-control-zoom .expand-button')
16+
17+
await expect(expand).toBeVisible()
18+
const zoomOutBox = (await zoomOut.boundingBox())!
19+
const expandBox = (await expand.boundingBox())!
20+
21+
expect(expandBox.width).toBe(zoomOutBox.width)
22+
expect(expandBox.x).toBe(zoomOutBox.x)
23+
expect(expandBox.y).toBeCloseTo(zoomOutBox.y + zoomOutBox.height, 0)
24+
})
25+
826
test('expanding the map covers the whole window', async ({ page }) => {
927
const viewport = page.viewportSize()!
1028

0 commit comments

Comments
 (0)