diff --git a/client/src/components/Map/ContainerResizeWatcher.test.tsx b/client/src/components/Map/ContainerResizeWatcher.test.tsx index f1e507ac7..8d659c618 100644 --- a/client/src/components/Map/ContainerResizeWatcher.test.tsx +++ b/client/src/components/Map/ContainerResizeWatcher.test.tsx @@ -9,13 +9,18 @@ const map = vi.hoisted(() => ({ getContainer: vi.fn(), getCenter: vi.fn(() => ({ lat: 35, lng: 135.7 })), getZoom: vi.fn(() => 12), + getSize: vi.fn(() => ({ x: 1376, y: 1032 })), + setView: vi.fn(), whenReady: vi.fn((fn: () => void) => fn()), on: vi.fn(), off: vi.fn(), })); vi.mock('react-leaflet', () => ({ useMap: () => map })); +vi.mock('./lngFrameContext', () => ({ useLngFrame: () => null })); -import ContainerResizeWatcher, { MapCameraSnapshot } from './ContainerResizeWatcher'; +import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport'; +import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher'; +import type { OpeningFrame } from './openingView'; const realObserver = globalThis.ResizeObserver; @@ -57,3 +62,49 @@ describe('ContainerResizeWatcher', () => { expect(map.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function)); }); }); + +describe('OpeningFrameFit', () => { + // A day across Kyoto from Arashiyama in the west to Ginkaku-ji in the east, wider than tall. + const points = [ + { lat: 35.0094, lng: 135.6668 }, + { lat: 35.005, lng: 135.7649 }, + { lat: 35.0037, lng: 135.7788 }, + { lat: 35.027, lng: 135.7982 }, + ]; + const padding = { top: 20, right: 40, bottom: 20, left: 40 }; + const windowFrame = (): OpeningFrame => { + const view = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding, width: 1376, height: 1032 }); + if (!view) throw new Error('the day has places to frame'); + return { points, padding, wrapsWorld: false, center: view.center, zoom: view.zoom, width: 1376, height: 1032 }; + }; + + afterEach(() => vi.clearAllMocks()); + + it('FE-MAP-RESIZE-004: a map beside the plan, narrower than the window, frames the day again for its own size', () => { + const frame = windowFrame(); + map.getSize.mockReturnValue({ x: 824, y: 1032 }); + map.getZoom.mockReturnValue(frame.zoom); + map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] }); + render(); + expect(map.setView).toHaveBeenCalledOnce(); + const [, zoom, options] = map.setView.mock.calls[0]; + // Further out than the window's frame, so the whole day fits the narrower map. + expect(zoom).toBeLessThan(frame.zoom); + expect(options).toEqual({ animate: false }); + }); + + it('FE-MAP-RESIZE-005: a map the size of the window, or one that opened at a kept camera, stays where it is', () => { + const frame = windowFrame(); + map.getSize.mockReturnValue({ x: 1376, y: 1000 }); + map.getZoom.mockReturnValue(frame.zoom); + map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] }); + const { unmount } = render(); + expect(map.setView).not.toHaveBeenCalled(); + unmount(); + + map.getSize.mockReturnValue({ x: 824, y: 1032 }); + map.getZoom.mockReturnValue(9); + render(); + expect(map.setView).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Map/ContainerResizeWatcher.tsx b/client/src/components/Map/ContainerResizeWatcher.tsx index b38a514b4..0ef31cb0a 100644 --- a/client/src/components/Map/ContainerResizeWatcher.tsx +++ b/client/src/components/Map/ContainerResizeWatcher.tsx @@ -1,6 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useMap } from 'react-leaflet'; +import { frameLng } from '../../utils/lngFrame'; +import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport'; +import { useLngFrame } from './lngFrameContext'; +import type { OpeningFrame } from './openingView'; + /** * Leaflet measures its container on window resizes only. The touch shell on a * wide window resizes the map without one: the trip's plan pane opens and @@ -36,3 +41,41 @@ export function MapCameraSnapshot({ onChange }: { onChange: (center: [number, nu }, [map, onChange]); return null; } + +/** + * The map opens framed on the trip's places, a frame worked out before the + * map exists and so for the window's size. Where the map is much smaller + * than the window (the plan beside it on an iPad or an unfolded foldable), + * that frame is too close and the day runs off the map. Once the map knows + * its size, the frame is worked out again for it. A map that opened anywhere + * else (a camera kept from before a remount) is left where it is. + */ +export function OpeningFrameFit({ frame }: { frame: OpeningFrame }) { + const map = useMap(); + const lngFrame = useLngFrame(); + const done = useRef(false); + useEffect(() => { + if (done.current) return; + done.current = true; + const { x, y } = map.getSize(); + if (x <= 0 || y <= 0) return; + if (Math.abs(x - frame.width) <= frame.width * 0.1 && Math.abs(y - frame.height) <= frame.height * 0.1) return; + const at = map.getCenter(); + const opened = + Math.abs(map.getZoom() - frame.zoom) < 0.01 && + Math.abs(at.lat - frame.center[0]) < 1e-6 && + Math.abs(at.lng - frame.center[1]) < 1e-6; + if (!opened) return; + const view = computeMapViewport(frame.points, { + tileSize: TILE_SIZE_RASTER, + padding: frame.padding, + wrapsWorld: frame.wrapsWorld, + width: x, + height: y, + }); + // A whole zoom level, as Leaflet snaps to them, and the one that fits rather than the nearest. + if (view) + map.setView([view.center[0], frameLng(lngFrame, view.center[1])], Math.floor(view.zoom), { animate: false }); + }, [map, lngFrame, frame]); + return null; +} diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 49aed5572..e99ff1a57 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from '../../../tests/helpers/render' import { act, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { resetAllStores } from '../../../tests/helpers/store' +import { setSizeClass } from '../../../tests/helpers/sizeClass' import { buildPlace, buildReservation } from '../../../tests/helpers/factories' import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults' import { useAuthStore } from '../../store/authStore' @@ -16,6 +17,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup' const mapMock = vi.hoisted(() => ({ getContainer: vi.fn(() => document.createElement('div')), getCenter: vi.fn(() => ({ lat: 0, lng: 0 })), + // The map fills the window here, so the opening frame stands (OpeningFrameFit). + getSize: vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })), panTo: vi.fn(), setView: vi.fn(), fitBounds: vi.fn(), @@ -204,6 +207,13 @@ vi.mock('../../services/photoService', () => ({ getAllThumbs: vi.fn(() => ({})), })) +// The app shows the touch shell at every size; a test can say it does (undefined: as in a browser). +const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined })) +vi.mock('../../mobile/useIsPhone', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useIsPhone: () => shell.phone ?? actual.useIsPhone() } +}) + vi.mock('./gcj02Crs', () => ({ crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined, })) @@ -227,6 +237,7 @@ const ORIGINAL_WIDTH = window.innerWidth afterEach(() => { vi.clearAllMocks() + shell.phone = undefined resetAllStores() clusterMock.markers.clear() clusterMock.asked.length = 0 @@ -1296,7 +1307,8 @@ describe('MapView selection panning (#921)', () => { afterEach(() => { delete projectable.project delete projectable.unproject - delete projectable.getSize + // Back to the map that fills the window (the mock's own size). + projectable.getSize = vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })) }) it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => { @@ -1574,6 +1586,24 @@ describe('MapView bounds fitting', () => { expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] }) }) + it('FE-COMP-MAPVIEW-094: opens framed at the whole zoom level that fits, not the nearest one', () => { + // A day spread so that its exact fit lies between two levels. + const places = [buildMapPlace({ id: 1, lat: 34.9671, lng: 135.7727 }), buildMapPlace({ id: 2, lat: 35.005, lng: 135.7649 })] + render() + const zoom = Number(screen.getByTestId('map-container').getAttribute('data-zoom')) + expect(Number.isInteger(zoom)).toBe(true) + }) + + it('FE-COMP-MAPVIEW-093: on an iPad the touch shell frames a day clear of its floating toolbar and corner buttons', () => { + shell.phone = true + setSizeClass('wide') + const places = [buildMapPlace({ id: 1, lat: 35, lng: 135.7 }), buildMapPlace({ id: 2, lat: 35.04, lng: 135.78 })] + const { rerender } = render() + rerender() + const opts = mapMock.fitBounds.mock.calls[mapMock.fitBounds.mock.calls.length - 1]?.[1] + expect(opts).toMatchObject({ paddingTopLeft: [40, 84], paddingBottomRight: [40, 56] }) + }) + it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => { const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]] const { rerender } = render( diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 605aabd58..c4025fce1 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback, memo, type Component import DOM from 'react-dom' import { categoryIconSvg } from './categoryIconSvg' import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet' -import ContainerResizeWatcher, { MapCameraSnapshot } from './ContainerResizeWatcher' +import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher' +import { openingView } from './openingView' import MarkerClusterGroup from 'react-leaflet-cluster' import { makeMarkerDraggable } from './markerDrag' import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster' @@ -74,7 +75,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { TourMapLayersLeaflet } from './RasterOverlayLeaflet' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' -import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport' +import type { ViewportPadding } from '../../utils/mapViewport' import { parseRenderableRouteGeometry } from '../../utils/routeGeometry' import type { MapViewProps } from './mapViewContract' @@ -408,6 +409,7 @@ import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto' import { useGeolocation } from '../../hooks/useGeolocation' import LocationButton from './LocationButton' import { useIsPhone } from '../../mobile/useIsPhone' +import { useIsWide } from '../../hooks/sizeClass' // Live-location rendering inside the Leaflet map. Subscribes via the // shared useGeolocation hook so the Leaflet and Mapbox variants behave @@ -677,15 +679,22 @@ export const MapView = memo(function MapView({ // the breakpoint twice, and the padding here and the locate button further // down used to stay with whatever the map saw when it mounted. const isMobile = useIsPhone() + // The touch shell on an iPad or an unfolded foldable floats its toolbar over the + // top of the map and its buttons in the corners, and its pins stand above their + // spot: a day framed to the phone's thin margin ran under the toolbar. + const isWide = useIsWide() const paddingBox = useMemo((): ViewportPadding => { - if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 } + if (isMobile) { + const margin = { top: 20, right: 40, bottom: 20, left: 40 } + return isWide ? { ...margin, top: 84, bottom: 56 } : margin + } return { top: 60, right: rightWidth + 40, bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }), left: leftWidth + 40, } - }, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail]) + }, [isMobile, isWide, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail]) const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox]) @@ -694,16 +703,9 @@ export const MapView = memo(function MapView({ // The initializer runs once, at mount — exactly when this should be decided; afterwards the // camera belongs to the user. `framed` is false when no place has coordinates (a new trip), // and then the caller's center/zoom stands. - const [initialView] = useState(() => { - const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, { - tileSize: TILE_SIZE_RASTER, - padding: paddingBox, - // With a longitude frame the trip lies on one copy, so it can be framed the short way. - wrapsWorld: lngFrame !== null, - }) - const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined - return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null } - }) + const [initialView] = useState(() => + openingView(dayPlaces.length > 0 ? dayPlaces : places, { padding: paddingBox, lngFrame, center, zoom }), + ) const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null) const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => { cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom } @@ -940,6 +942,7 @@ export const MapView = memo(function MapView({ + {initialView.refit && } {hasViewBaseLayer && } 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} /> diff --git a/client/src/components/Map/openingView.ts b/client/src/components/Map/openingView.ts new file mode 100644 index 000000000..555893981 --- /dev/null +++ b/client/src/components/Map/openingView.ts @@ -0,0 +1,42 @@ +import { frameLng, type LngFrame } from '../../utils/lngFrame'; +import { computeMapViewport, TILE_SIZE_RASTER, type GeoPointish, type ViewportPadding } from '../../utils/mapViewport'; + +/** + * Where MapView opens: framed on the places when they have coordinates, else + * at the caller's centre and zoom. Leaflet snaps to whole zoom levels and + * rounds to the nearest, so 14.7 became 15 and the day ran off the map; the + * whole level below always fits. `refit` keeps what the frame was worked out + * for (the window), so OpeningFrameFit can work it out again for the map's + * own size once it has one. + */ +export function openingView( + points: readonly GeoPointish[], + options: { padding: ViewportPadding; lngFrame: LngFrame | null; center: [number, number]; zoom: number } +): { center: [number, number]; zoom: number; framed: boolean; refit: OpeningFrame | null } { + // With a longitude frame the trip lies on one copy, so it can be framed the short way. + const wrapsWorld = options.lngFrame !== null; + const fitted = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding: options.padding, wrapsWorld }); + if (!fitted) return { center: options.center, zoom: options.zoom, framed: false, refit: null }; + const center: [number, number] = [fitted.center[0], frameLng(options.lngFrame, fitted.center[1])]; + const zoom = Math.floor(fitted.zoom); + const width = typeof window !== 'undefined' && window.innerWidth > 0 ? window.innerWidth : 1024; + const height = typeof window !== 'undefined' && window.innerHeight > 0 ? window.innerHeight : 768; + return { + center, + zoom, + framed: true, + refit: { points, padding: options.padding, wrapsWorld, center, zoom, width, height }, + }; +} + +/** The opening frame as MapView worked it out before the map existed: for the window. */ +export interface OpeningFrame { + points: readonly GeoPointish[]; + padding: ViewportPadding; + wrapsWorld: boolean; + center: [number, number]; + zoom: number; + /** The size it was worked out for: the window's. */ + width: number; + height: number; +} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 8eb2ca81a..be650be78 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -210,7 +210,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { )} {category && ( - + {category.name} diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx index 065d815c9..9f483f12f 100644 --- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx +++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx @@ -48,6 +48,12 @@ describe('MReservationCardParts', () => { expect(onOpen).toHaveBeenCalledOnce(); }); + it('FE-COMP-MRESCARD-011: the title keeps one line on a phone and may take two in a wide window column', () => { + render(); + const title = screen.getByText('LH 400'); + expect(title).toHaveClass('truncate', 'wide:line-clamp-2', 'wide:whitespace-normal'); + }); + it('FE-COMP-MRESCARD-002: the title button flags a booking that needs review (booking variant)', () => { render( {t(`reservations.type.${res.type}`)} - {res.title} + + {res.title} + {!!res.needs_review && ( {t('reservations.needsReview')} diff --git a/client/src/mobile/screens/trip/tabs/tabChrome.tsx b/client/src/mobile/screens/trip/tabs/tabChrome.tsx index 32edfbbd3..90aad4ca5 100644 --- a/client/src/mobile/screens/trip/tabs/tabChrome.tsx +++ b/client/src/mobile/screens/trip/tabs/tabChrome.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useLayoutEffect, useRef, type ReactNode } from 'react' import { ChevronDown, ChevronUp } from 'lucide-react' import type { ReservationTraveler } from '@trek/shared' import { avatarSrc } from '../../../../utils/avatarSrc' @@ -37,16 +37,19 @@ const TAB_INSETS = 'px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(- const PANE_INSETS = 'pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]' /** - * The cards of a list tab (transports, bookings, tasks): one column on a phone; - * on a wide window as many columns of at least a phone's width as the page - * holds, three on a 13" iPad on its side, two upright or on an unfolded - * foldable on its side, one in a narrow Split View. Cards stay in reading - * order along the rows and keep their own height: stretched to a taller - * neighbour, a short booking read as an empty panel. + * The cards of a list tab (transports, bookings, tasks, polls): one column on a + * phone; on a wide window as many columns of at least a phone's width as the + * page holds, three on a 13" iPad on its side, two upright or on an unfolded + * foldable on its side, one in a narrow Split View. The cards flow down the + * columns, each keeping its own height, so the columns start level and no + * short card ends above its neighbour in a ragged row or stretches into an + * empty panel. In date order that reads down the first column, then the next. + * The cards' own top margin gives way to an even gap under each card, so the + * columns start on one line in every engine. */ export function CardGrid({ children }: { children: ReactNode }) { return ( -
+
{children}
) @@ -60,29 +63,68 @@ export function CardGrid({ children }: { children: ReactNode }) { * list moves. On a foldable that reports its fold, the aside ends at the fold. * Narrower windows stack the two in one scroll body, the aside first, exactly * as the phone shows them. + * + * Both arrangements are one element tree with other classes, so a rotation + * or a fold between them keeps what is typed or open in either pane, and the + * reader keeps their place: the row at the top of the list stays at the top. */ export function TabPanes({ aside, asideLabel, children }: { aside: ReactNode; asideLabel: string; children: ReactNode }) { const split = useIsSplit() - if (!split) { - return ( - - {aside} - {children} - - ) + const body = useRef(null) + const list = useRef(null) + // How far the list has moved under the top edge of its scroll body, read on + // every scroll; 0 while its start is in view (or the summary above it, + // stacked). Stacked, the list starts under the summary in the one scroll + // body; split, it scrolls on its own. A change of arrangement puts it back. + const into = useRef(0) + useLayoutEffect(() => { + const el = split ? list.current : body.current + const top = list.current + if (!el || !top || into.current <= 0) return + el.scrollTop = split ? into.current : listStartIn(el, top) + into.current + }, [split]) + const place = () => { + const el = split ? list.current : body.current + const top = list.current + if (!el || !top) return + into.current = split ? el.scrollTop : Math.max(0, el.scrollTop - listStartIn(el, top)) } return ( -
- -
-
{children}
+
+
*]:mx-auto wide:[&>*]:max-w-[760px]` + } + > +
+ {aside} +
+
+
{children}
+
) } +/** Where the list starts in its scroll body's content, under the body's top padding. */ +function listStartIn(el: HTMLElement, top: HTMLElement): number { + const pad = parseFloat(getComputedStyle(el).paddingTop) || 0 + return top.getBoundingClientRect().top - el.getBoundingClientRect().top - pad + el.scrollTop +} + /** Small count badge next to a section label / settle row (spec 03 §1.1). */ export function CountPill({ children }: { children: ReactNode }) { return ( diff --git a/client/tests/unit/mobile/largeScreenPanes.test.tsx b/client/tests/unit/mobile/largeScreenPanes.test.tsx index 9699403e3..bc12e4057 100644 --- a/client/tests/unit/mobile/largeScreenPanes.test.tsx +++ b/client/tests/unit/mobile/largeScreenPanes.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import { Settings2, Users } from 'lucide-react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -189,15 +190,19 @@ describe('section lists beside the page', () => { }); describe('list tabs on a wide window', () => { - it('FE-MOB-PANES-009: cards take as many columns of a phone width as the page holds', () => { + it('FE-MOB-PANES-009: cards flow down as many columns of a phone width as the page holds, starting level', () => { const { container } = render(
a
); - expect((container.firstChild as HTMLElement).className).toContain( - 'wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))]' - ); + const grid = container.firstChild as HTMLElement; + expect(grid.className).toContain('wide:columns-[340px]'); + // No card splits across two columns, and none keeps a top margin that + // would start its column lower than the next. + expect(grid.className).toContain('wide:[&>*]:break-inside-avoid'); + expect(grid.className).toContain('wide:[&>*]:mt-0'); + expect(grid.className).not.toContain('wide:grid'); }); it('FE-MOB-PANES-010: the summary stacks above the list, and stands beside it with room for two panes', () => { @@ -207,7 +212,10 @@ describe('list tabs on a wide window', () => { ); expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); - expect(screen.getByText('summary').parentElement).toBe(screen.getByText('list').parentElement); + // Stacked: one scroll body holds both, the summary first. + const scroller = screen.getByText('summary').closest('.overflow-y-auto'); + expect(scroller).not.toBeNull(); + expect(scroller).toBe(screen.getByText('list').closest('.overflow-y-auto')); setSizeClass('split'); rerender( @@ -221,6 +229,28 @@ describe('list tabs on a wide window', () => { expect(within(aside).queryByText('list')).not.toBeInTheDocument(); }); + it('FE-MOB-PANES-016: turning between stacked and two panes keeps what is typed in either pane', async () => { + const user = userEvent.setup(); + const panes = () => ( + } asideLabel="Budget"> + + + ); + const { rerender } = render(panes()); + await user.type(screen.getByLabelText('search'), 'ramen'); + await user.type(screen.getByLabelText('budget note'), 'cap'); + + setSizeClass('split'); + rerender(panes()); + expect(screen.getByLabelText('search')).toHaveValue('ramen'); + expect(screen.getByLabelText('budget note')).toHaveValue('cap'); + + setSizeClass('wide'); + rerender(panes()); + expect(screen.getByLabelText('search')).toHaveValue('ramen'); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + }); + it('FE-MOB-PANES-011: the to-do filters as tiles and lists, each one a filter to pick', () => { const onPick = vi.fn(); render( diff --git a/client/tests/unit/mobile/trip/MBookingsTab.test.tsx b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx index cc9e76e1d..e286eb338 100644 --- a/client/tests/unit/mobile/trip/MBookingsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx @@ -328,11 +328,11 @@ describe('MBookingsTab', () => { expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument() }) - it('FE-MOB-BKTAB-024: a section lays its cards in a grid that takes as many columns as the page holds', () => { + it('FE-MOB-BKTAB-024: a section lays its cards in as many columns as the page holds', () => { renderTab() const header = screen.getByRole('button', { name: 'reservations.confirmed2' }) const grid = header.nextElementSibling as HTMLElement - expect(grid.className).toContain('wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))]') + expect(grid.className).toContain('wide:columns-[340px]') expect(within(grid).getByText('Hotel Granvia')).toBeInTheDocument() }) }) diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx index 0be0614db..3ccdd2d25 100644 --- a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -173,6 +173,12 @@ describe('MPlaceSheet', () => { expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument() }) + it('FE-MOB-PLSH-033: the category pill keeps to the photo on a phone and takes the room a wide sheet has', () => { + renderSheet() + const pill = screen.getByText('Museum').parentElement as HTMLElement + expect(pill).toHaveClass('max-w-[76px]', 'wide:max-w-[140px]') + }) + it('FE-MOB-PLSH-003: clears the place selection when closed', () => { const { planner } = renderSheet() fireEvent.click(screen.getByRole('button', { name: 'Close' }))