From 9b415c8ab99e2325d3ee696f2fd0b21a6af4853d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 16 Sep 2026 23:21:40 +0000 Subject: [PATCH] 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. --- .../Map/MapAttributionToggle.test.tsx | 45 +++++++++ .../components/Map/MapAttributionToggle.tsx | 15 ++- client/src/components/Map/MapView.test.tsx | 26 +++++ client/src/mobile/mobile.css | 65 ++++++++++++- client/src/mobile/mobileCreditCss.test.ts | 94 +++++++++++++++++++ .../src/mobile/screens/trip/map/MMapArea.tsx | 36 ++++--- .../tests/unit/mobile/trip/MMapArea.test.tsx | 54 +++++++++-- 7 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 client/src/components/Map/MapAttributionToggle.test.tsx create mode 100644 client/src/mobile/mobileCreditCss.test.ts diff --git a/client/src/components/Map/MapAttributionToggle.test.tsx b/client/src/components/Map/MapAttributionToggle.test.tsx new file mode 100644 index 000000000..ada4001d8 --- /dev/null +++ b/client/src/components/Map/MapAttributionToggle.test.tsx @@ -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( {}} 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() + 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() + 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( {}} 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') + }) +}) diff --git a/client/src/components/Map/MapAttributionToggle.tsx b/client/src/components/Map/MapAttributionToggle.tsx index b4da0f194..d42a4aa87 100644 --- a/client/src/components/Map/MapAttributionToggle.tsx +++ b/client/src/components/Map/MapAttributionToggle.tsx @@ -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, diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index eb17a6ad5..a2836d124 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -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() + expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull() + desktop.unmount() + + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) + const { container } = render() + 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', () => { diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index 9706c62f0..cc4719c9c 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -355,7 +355,9 @@ * tucking it away only on the first drag. On a phone, where the map IS the screen, * the first thing anybody sees is then a two-line grey ribbon over the bottom of * it. MapViewGL now starts it collapsed; what is left here is where the little - * button sits, which is beside the locate control rather than underneath it. + * button sits on a phone map without a credit row, which is beside the locate + * control rather than underneath it. The trip map gives it the corner instead + * (`.m-credit-corner` below). * * Nothing is dropped: one tap opens the full credit, and it is still a link. */ .m-root .maplibregl-ctrl-bottom-right, @@ -364,10 +366,10 @@ right: 46px; } -/* Leaflet has no collapsed mode at all, so it is given one: hidden until the (i) beside - * the locate button is tapped (`MapAttributionToggle`, which puts `m-attrib-open` on the - * map's wrapper). Nothing is lost: one tap shows the whole credit, every link in it - * included, which is the same bargain the GL engines strike on a narrow map. */ +/* Leaflet has no collapsed mode at all, so it is given one: hidden until its (i) is tapped + * (`MapAttributionToggle`, which puts `m-attrib-open` on the map's wrapper). Nothing is + * lost: one tap shows the whole credit, every link in it included, which is the same + * bargain the GL engines strike on a narrow map. */ .m-root .leaflet-control-attribution { display: none; } /* The control column, not just the block inside it: Leaflet sizes that column to the @@ -391,6 +393,59 @@ } .m-root .m-attrib-open .leaflet-control-attribution a { color: var(--m-ink); } +/* On the trip map the credit has the bottom right corner to itself. + * + * Beside the locate button it read as a stray control in the middle of the band. MMapArea + * marks its layer `m-credit-corner`, defines `--m-map-floor` (the top of the dock, or of the + * stage bar on the road trip tab) and floats the round controls one credit row above that + * floor, so the corner under them is free. Both engines read the same two numbers from + * here: the GL control containers through the rules below, the Leaflet (i) through the + * variables it falls back from. Other phone maps have no such row and define neither, so + * their credit stays beside their locate button. */ +.m-root .m-credit-corner { + /* The locate button's own margin, so the right column lines up on one edge. */ + --m-credit-right: 12px; + --m-credit-bottom: calc(var(--m-map-floor) + 12px); +} + +/* The compact GL control keeps a 10px margin of its own, so its container sits that far + inside the spot the button is meant to land on. */ +.m-root .m-credit-corner .maplibregl-ctrl-bottom-right, +.m-root .m-credit-corner .mapboxgl-ctrl-bottom-right { + bottom: calc(var(--m-credit-bottom) - 10px); + right: calc(var(--m-credit-right) - 10px); +} + +/* ...and the (i) stays in that corner while the credit is open. Both GL engines pin their + button to the top of the control, so a credit that wraps carries it up the column, into + the locate button that now sits right above this corner: at two lines (20px each) the + top of the button slides under it, at three the whole button does, and the tap meant to + close the credit lands on the locate button instead. Held at the bottom it stays where + the thumb just was, and the text unfolds to its left and upward like the Leaflet credit + below. Collapsed, the control is exactly the button's height, so nothing moves until it + opens. */ +.m-root .m-credit-corner .maplibregl-ctrl-bottom-right .maplibregl-ctrl-attrib-button, +.m-root .m-credit-corner .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib-button { + top: auto; + bottom: 0; +} + +/* The Mapbox wordmark is a credit too, so it shares the row rather than rising with the + band into the switcher and the compass. 6px rather than 10: the logo hangs 4px below its + own control box. MapLibre draws no wordmark here, so for it this moves an empty corner. */ +.m-root .m-credit-corner .maplibregl-ctrl-bottom-left, +.m-root .m-credit-corner .mapboxgl-ctrl-bottom-left { + bottom: calc(var(--m-credit-bottom) - 6px); +} + +/* The open Leaflet credit stays in the row, to the left of the (i), which is where the GL + engines unfold theirs. Above the (i) is the locate button now. 36px is the (i) plus a + gap; both offsets are relative, like the ones they override. */ +.m-root .m-credit-corner .m-attrib-open .leaflet-control-attribution { + right: calc(var(--m-credit-right) + 36px); + bottom: var(--m-credit-bottom); +} + /* Text fields carry the row's background, not their own. * * iOS paints its own background inside a text control, and under diff --git a/client/src/mobile/mobileCreditCss.test.ts b/client/src/mobile/mobileCreditCss.test.ts new file mode 100644 index 000000000..d258adf3d --- /dev/null +++ b/client/src/mobile/mobileCreditCss.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// FE-MOB-CREDITCSS-001 to FE-MOB-CREDITCSS-005 +// +// Where the credit lands on the phone trip map is written in mobile.css, against the vendor +// control containers the GL engines draw and the Leaflet column. jsdom applies neither +// stylesheet, so these read the real file: the numbers only agree with the vendor CSS and +// with MMapArea's floor by convention, which is exactly what can drift here. +describe('phone map credit css', () => { + // Vitest runs with the client package as its root, so cwd is stable here. + const css = readFileSync(resolve(process.cwd(), 'src/mobile/mobile.css'), 'utf8') + + /** + * Every rule in the sheet as its selector list and its declarations, comments dropped. + * Parsed rather than found by substring, because the same selector heads more than one + * rule here (the generic lift and the round (i) both name the GL bottom right corner). + */ + const rules = css + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('}') + .filter(chunk => chunk.includes('{')) + .map(chunk => { + const open = chunk.lastIndexOf('{') + const head = chunk.slice(0, open) + return { + selectors: head.slice(head.lastIndexOf('{') + 1).split(',').map(s => s.trim()), + body: chunk.slice(open + 1), + } + }) + /** The rules whose selector list names this selector. */ + const rulesFor = (selector: string) => rules.filter(rule => rule.selectors.includes(selector)) + + it('FE-MOB-CREDITCSS-001: the credit corner derives both numbers from the floor MMapArea defines', () => { + const corner = rulesFor('.m-root .m-credit-corner') + + expect(corner).toHaveLength(1) + // The locate button's own margin on the right, a gap above the dock or the stage bar below. + expect(corner[0].body).toMatch(/--m-credit-right:\s*12px;/) + expect(corner[0].body).toMatch(/--m-credit-bottom:\s*calc\(var\(--m-map-floor\) \+ 12px\);/) + }) + + it('FE-MOB-CREDITCSS-002: the GL (i) lands on those numbers, net of the compact control\'s own margin', () => { + const gl = rulesFor('.m-root .m-credit-corner .maplibregl-ctrl-bottom-right') + + expect(gl).toHaveLength(1) + // One rule for both engines, so MapLibre and Mapbox cannot end up in different corners. + expect(gl[0].selectors).toContain('.m-root .m-credit-corner .mapboxgl-ctrl-bottom-right') + // The vendor sheets give the compact attribution a 10px margin on every side. + expect(gl[0].body).toMatch(/bottom:\s*calc\(var\(--m-credit-bottom\) - 10px\);/) + expect(gl[0].body).toMatch(/right:\s*calc\(var\(--m-credit-right\) - 10px\);/) + + // The Mapbox wordmark shares the row rather than rising with the band under the compass. + const wordmark = rulesFor('.m-root .m-credit-corner .mapboxgl-ctrl-bottom-left') + expect(wordmark).toHaveLength(1) + expect(wordmark[0].selectors).toContain('.m-root .m-credit-corner .maplibregl-ctrl-bottom-left') + expect(wordmark[0].body).toMatch(/bottom:\s*calc\(var\(--m-credit-bottom\) - 6px\);/) + }) + + it('FE-MOB-CREDITCSS-003: the open Leaflet credit unfolds in the row, left of the (i), not over the locate button', () => { + const open = rulesFor('.m-root .m-credit-corner .m-attrib-open .leaflet-control-attribution') + + expect(open).toHaveLength(1) + expect(open[0].body).toMatch(/right:\s*calc\(var\(--m-credit-right\) \+ 36px\);/) + expect(open[0].body).toMatch(/bottom:\s*var\(--m-credit-bottom\);/) + }) + + it('FE-MOB-CREDITCSS-004: phone maps without a credit row keep the credit beside their locate button', () => { + // The collections and journey maps have no `m-credit-corner`, so the unscoped rules are + // still what places their credit. + expect(rulesFor('.m-root .maplibregl-ctrl-bottom-right').some(rule => /right:\s*46px;/.test(rule.body))).toBe(true) + expect(rulesFor('.m-root .m-attrib-open .leaflet-control-attribution').some( + rule => /bottom:\s*calc\(var\(--bottom-nav-h\) \+ 52px\);/.test(rule.body), + )).toBe(true) + + // The band's own lift stays in MMapArea's class, where FE-MOB-MAPAREA-003 pins it. A + // corner rule setting it too would move every round control from a stylesheet. + const corner = rules.filter(rule => rule.selectors.some(s => s.includes('.m-credit-corner'))) + expect(corner).toHaveLength(5) + for (const rule of corner) expect(rule.body).not.toContain('--bottom-nav-h') + }) + + it('FE-MOB-CREDITCSS-005: an open GL credit keeps its (i) in the corner rather than under the locate button', () => { + const button = rulesFor('.m-root .m-credit-corner .maplibregl-ctrl-bottom-right .maplibregl-ctrl-attrib-button') + + expect(button).toHaveLength(1) + expect(button[0].selectors).toContain('.m-root .m-credit-corner .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib-button') + // Both vendor sheets put the button at the top of the control, so a credit wrapping to + // three lines would carry it up into the locate button right above the corner. + expect(button[0].body).toMatch(/top:\s*auto;/) + expect(button[0].body).toMatch(/bottom:\s*0;/) + }) +}) diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx index 23a0ad7fd..650cc7993 100644 --- a/client/src/mobile/screens/trip/map/MMapArea.tsx +++ b/client/src/mobile/screens/trip/map/MMapArea.tsx @@ -59,10 +59,11 @@ function holdFocus(prev: HeldFocus, points: readonly [number, number][], dayId: * day-order badges, dashed day route, transport overlays per booking, POI * explore markers and long-press → add place. Only the floating chrome is * mobile: the POI bar spans the full width below the day-chip rail, and the round - * controls share the band just above the dock: the map's own base-layer switcher + * controls share one band above the dock: the map's own base-layer switcher * with the compass beside it on the left, the map's built-in three-state locate * button on the right, all riding the --bottom-nav-h contract the map already reads - * so they cannot drift apart. + * so they cannot drift apart. The map credit sits under that band, alone in the + * bottom right corner. * * On the plan tab, marker data honours the shared places category filter (#1541) * because planner.mapPlaces is derived from tripStore's placesCategoryFilter, the @@ -178,18 +179,29 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { // `isolate` keeps the map's internal z-indexes (Leaflet panes, the z-1000 // locate button) inside this layer so they can never paint over the plan // timeline (z-10) or the browse/tab overlays (z-30) above it. - // The dock is 62px tall at safe-bottom + 12, so a 74px --bottom-nav-h puts - // the round controls (which add their own 12px) a dock's gap above it — - // close enough to the thumb to reach one-handed, clear of the dock itself. - // The stage bar takes the band the round controls normally sit in, so on that - // tab they move up by its height plus its own gap. The lift is its OWN variable - // folded into the class rather than an inline --bottom-nav-h: the compass band - // below is identified by being the one element that sets that name inline, and a - // second one would make that ambiguous. + // + // --m-map-floor is the top edge of whatever the map ends at: the dock, 62px tall at + // safe-bottom + 12, or on the road trip tab the stage bar, which takes the band the + // round controls would otherwise sit in and is what --m-stage-lift adds. The map + // credit (the little (i)) takes the bottom right corner a gap above that floor: + // beside the locate button it read as a stray control in the middle of the band. + // The round controls float one credit row higher (a 30px button plus an 8px gap) and + // add their own 12px on top of that, still close enough to the thumb to reach + // one-handed. The whole band moves rather than the one control over the corner, so + // the compass and the locate button stay on one line, and everything that reads + // --bottom-nav-h (the compass, both engines' locate button and base-layer switcher, + // the overview stack) follows on its own. Where the credit lands is written once, in + // mobile.css under `m-credit-corner`, where the GL containers and the Leaflet (i) + // both read it. + // + // The metrics are classes rather than an inline --bottom-nav-h, and the lift is its + // OWN variable folded into them: the compass band below is identified by being the + // one element that sets that name inline, and a second one would make that ambiguous.
{ expect(compassBand(container)?.className).not.toContain('left-3') }) - it('FE-MOB-MAPAREA-003: the map layer floats those controls a dock gap above the dock', () => { + it('FE-MOB-MAPAREA-003: the map layer floats those controls one credit row above the dock', () => { const { container } = renderArea() + const layer = container.firstElementChild as HTMLElement - // The dock is 62px tall at safe-bottom + 12; the controls add their own 12. - expect((container.firstElementChild as HTMLElement).className) - .toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]') + // The floor is the dock's top edge: 62px tall at safe-bottom + 12, raised by the stage lift. + expect(layer.className) + .toContain('[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]') + // The band sits one credit row (a 30px button plus an 8px gap) above that floor, so the + // corner under it is the credit's; the controls add their own 12 on top. + expect(layer.className).toContain('[--bottom-nav-h:calc(var(--m-map-floor)+38px)]') // No lift off the stage: the plan tab has no bar in that band. - expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px') + expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('0px') }) - it('FE-MOB-MAPAREA-013: on the stage the controls clear the stage bar by one gap', () => { + it('FE-MOB-MAPAREA-013: on the stage the map floor clears the stage bar by one gap', () => { const { container } = renderArea({ trTab: 'roadtrip', mapFront: true }) - // The bar is 61px tall and keeps a gap on both sides, so the round controls land - // above it rather than on its top edge. + // The bar is 61px tall and keeps a gap on both sides, so the floor rises above it rather + // than stopping on its top edge, and the credit and the round controls rise with it. expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('76px') }) @@ -490,4 +494,36 @@ describe('MMapArea', () => { rerender() expect(mocks.props.focusPoints).toBe(shown) }) + + it('FE-MOB-MAPAREA-026: the map layer is the credit corner on both tabs, and on the stage it rides the lift', () => { + const plan = renderArea() + const planLayer = plan.container.firstElementChild as HTMLElement + // mobile.css places the credit off this class, in both GL engines and in Leaflet. + expect(planLayer.classList.contains('m-credit-corner')).toBe(true) + plan.unmount() + + const { container } = renderArea({ trTab: 'roadtrip', mapFront: true }) + const stageLayer = container.firstElementChild as HTMLElement + expect(stageLayer.classList.contains('m-credit-corner')).toBe(true) + // The floor takes the same 76px the band does, so the credit lands a gap above the + // stage bar rather than on it. + expect(stageLayer.style.getPropertyValue('--m-stage-lift')).toBe('76px') + }) + + it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => { + for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial[]) { + const { container, unmount } = renderArea(shellOver) + const inline = (container.firstElementChild as HTMLElement).getAttribute('style') ?? '' + + // The floor and the band are classes: a second element naming --bottom-nav-h inline + // would leave nothing to tell the compass band apart by. + expect(inline).toContain('--m-stage-lift') + expect(inline).not.toContain('--bottom-nav-h') + expect(inline).not.toContain('--m-map-floor') + expect(inline).not.toContain('--m-credit') + expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1) + expect(compassBand(container)).not.toBeNull() + unmount() + } + }) })