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() + } + }) })