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' }))