feat(maps): the phone trip map keeps its credit in the bottom right corner

The credit button sat in the middle of the round controls band, beside the
locate button. On the trip map it now has the bottom right corner to
itself, a gap above the dock (or above the stage bar on the road trip
tab), and the band floats one credit row higher, so the switcher, the
compass and the locate button stay on one line.

MMapArea marks its layer as a credit corner and defines the floor the map
ends at; mobile.css places both GL engines' compact control there, keeps
an opened GL credit's button in the corner, moves the Mapbox wordmark into
the same row instead of under the compass, and unfolds the Leaflet credit
to the left of its button. MapAttributionToggle reads the same two
variables with today's values as the fallback, so the other phone maps and
the desktop keep their placement.
This commit is contained in:
Maurice
2026-09-16 23:21:40 +00:00
parent bf0ec85372
commit 9b415c8ab9
7 changed files with 305 additions and 30 deletions
@@ -0,0 +1,45 @@
// FE-MAP-ATTRIB-001 to FE-MAP-ATTRIB-003
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MapAttributionToggle from './MapAttributionToggle'
// Outside a TranslationProvider t(key) echoes the key, so the label is asserted as its key.
const BAND = 'calc(var(--bottom-nav-h, 84px) + 12px)'
describe('MapAttributionToggle', () => {
it('FE-MAP-ATTRIB-001: it follows a credit corner where the map has one, and keeps the locate band where it has not', () => {
const { getByRole } = render(<MapAttributionToggle open={false} onToggle={() => {}} bottomOffset={BAND} />)
const style = getByRole('button').style
// Both numbers come from the map around it first. A map without a credit corner
// defines neither variable, so the fallbacks are the old slot beside the locate button.
expect(style.right).toBe('var(--m-credit-right, 62px)')
expect(style.bottom).toBe(`var(--m-credit-bottom, ${BAND})`)
})
it('FE-MAP-ATTRIB-002: it is labelled, says whether the credit is open, and toggles once per tap', () => {
const onToggle = vi.fn()
const { getByRole, rerender } = render(<MapAttributionToggle open={false} onToggle={onToggle} bottomOffset={BAND} />)
const button = getByRole('button')
expect(button.getAttribute('title')).toBe('map.attribution')
expect(button.getAttribute('aria-label')).toBe('map.attribution')
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
expect(onToggle).toHaveBeenCalledTimes(1)
rerender(<MapAttributionToggle open onToggle={onToggle} bottomOffset={BAND} />)
expect(getByRole('button').getAttribute('aria-expanded')).toBe('true')
})
it('FE-MAP-ATTRIB-003: wherever it sits, it stays above the Leaflet control corners', () => {
const { getByRole } = render(<MapAttributionToggle open={false} onToggle={() => {}} bottomOffset={BAND} />)
const style = getByRole('button').style
// index.css pins `.leaflet-bottom` to z-index 1, and the open credit lives in that
// corner. The (i) sits in the same row now, so it has to stay the one on top.
expect(style.position).toBe('absolute')
expect(style.zIndex).toBe('1000')
})
})
@@ -5,7 +5,10 @@ import { useTranslation } from '../../i18n'
interface MapAttributionToggleProps {
open: boolean
onToggle: () => void
/** Same band as the locate button, so the two sit on one line. */
/**
* Where the (i) sits on a map that defines no credit corner: the locate button's band,
* so the two share a line.
*/
bottomOffset: string
}
@@ -34,9 +37,13 @@ export default function MapAttributionToggle({ open, onToggle, bottomOffset }: M
aria-expanded={open}
style={{
position: 'absolute',
bottom: bottomOffset,
// Clear of the locate button at right: 12 plus its 42, with a gap.
right: 62,
// A map that gives the credit a corner of its own (the phone trip map, see
// `m-credit-corner` in mobile.css) places the (i) through these two variables, the
// same two its GL engines are placed by. Every other map defines neither, so the
// fallbacks keep the button where it always was: in the locate button's band,
// clear of that button at right: 12 plus its 42, with a gap.
bottom: `var(--m-credit-bottom, ${bottomOffset})`,
right: 'var(--m-credit-right, 62px)',
zIndex: 1000,
width: 30,
height: 30,
@@ -988,6 +988,32 @@ describe('MapView live location', () => {
fireEvent.click(screen.getByRole('button', { name: 'Follow my location' }))
expect(geoMock.cycleMode).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-077: the credit (i) is a phone control, and it opens the credit through a class on the wrapper', () => {
const desktop = render(<MapView />)
expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull()
desktop.unmount()
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
const { container } = render(<MapView />)
const toggle = screen.getByRole('button', { name: 'Map credits' })
const wrapper = container.querySelector('div.w-full.h-full.relative') as HTMLElement
// Starts closed: on a phone the map is the screen, and the credit is one tap away.
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(wrapper.classList.contains('m-attrib-open')).toBe(false)
// The locate button's band is only the fallback; a credit corner around the map wins.
expect(toggle.style.bottom).toBe('var(--m-credit-bottom, calc(var(--bottom-nav-h, 84px) + 12px))')
// A class on the wrapper rather than markup in Leaflet's own container, which Leaflet
// rewrites whenever a layer with an attribution comes or goes.
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(wrapper.classList.contains('m-attrib-open')).toBe(true)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(wrapper.classList.contains('m-attrib-open')).toBe(false)
})
})
describe('MapView bounds fitting', () => {