mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
fix(mobile): level card columns, the day framed beside the plan, the reader's place kept across a fold
From a screen by screen pass over the iPad and foldable sizes: - Card lists (transports, bookings, tasks, polls) flow down their columns instead of filling rows, so the columns start level and a short card no longer ends above its neighbour in a ragged row. The cards' own top margin gives way to an even gap under each, so every column starts on one line. - A booking's or transport's title may take two lines in a column of a wide window instead of being cut after a few words. - The map beside the plan opened too close: the opening frame was worked out for the window, not the narrower map, and Leaflet rounded its zoom up to the next whole level, so the day ran off the top and bottom. The map now opens at the whole level that fits and, once it knows its own size, works the frame out again for it (OpeningFrameFit). On a wide window the touch shell frames a day clear of its floating toolbar and corner buttons. - Costs and to-dos in two panes are one element tree with the stacked arrangement, so a rotation or a fold between them keeps what is typed or open in either pane, and the row at the top of the list stays at the top. - The place sheet's category pill has room for its name on a wide window.
This commit is contained in:
@@ -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(<OpeningFrameFit frame={frame} />);
|
||||
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(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
map.getSize.mockReturnValue({ x: 824, y: 1032 });
|
||||
map.getZoom.mockReturnValue(9);
|
||||
render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof import('../../mobile/useIsPhone')>()
|
||||
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(<MapView places={places} />)
|
||||
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(<MapView places={places} fitKey={1} />)
|
||||
rerender(<MapView places={places} fitKey={2} />)
|
||||
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(
|
||||
|
||||
@@ -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({
|
||||
<LngFrameFollower />
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<ContainerResizeWatcher />
|
||||
{initialView.refit && <OpeningFrameFit frame={initialView.refit} />}
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
|
||||
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
)}
|
||||
</PlacePhotoGallery>
|
||||
{category && (
|
||||
<span className="flex max-w-[76px] items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted">
|
||||
<span className="flex max-w-[76px] items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted wide:max-w-[140px]">
|
||||
<CatIcon size={10} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
|
||||
@@ -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(<CardTitleButton res={reservation()} TypeIcon={Plane} typeColor="#123456" onOpen={vi.fn()} t={t} />);
|
||||
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(
|
||||
<CardTitleButton
|
||||
|
||||
@@ -37,7 +37,9 @@ export function CardTitleButton({
|
||||
<TypeIcon size={10} strokeWidth={2.2} style={{ color: typeColor }} />
|
||||
{t(`reservations.type.${res.type}`)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink">{res.title}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink wide:line-clamp-2 wide:whitespace-normal">
|
||||
{res.title}
|
||||
</span>
|
||||
{!!res.needs_review && (
|
||||
<span className="flex-none rounded-full bg-[rgba(232,161,58,.16)] px-2 py-[2px] font-geist text-[0.5rem] font-bold uppercase tracking-[.03em] text-[color:var(--m-st-pending)]">
|
||||
{t('reservations.needsReview')}
|
||||
|
||||
@@ -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 (
|
||||
<div className="wide:grid wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))] wide:items-start wide:gap-x-3">
|
||||
<div className="wide:columns-[340px] wide:gap-x-3 wide:pt-2 wide:[&>*]:mb-3 wide:[&>*]:mt-0 wide:[&>*]:break-inside-avoid">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -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 (
|
||||
<TabScroller>
|
||||
{aside}
|
||||
{children}
|
||||
</TabScroller>
|
||||
)
|
||||
const body = useRef<HTMLDivElement>(null)
|
||||
const list = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-full">
|
||||
<aside aria-label={asideLabel} className={`m-pane-aside min-h-0 flex-none overflow-y-auto overflow-x-hidden ps-6 pe-3 ${PANE_INSETS}`}>
|
||||
{aside}
|
||||
</aside>
|
||||
<div className={`min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-6 ps-3 ${PANE_INSETS}`}>
|
||||
<div className="mx-auto max-w-[760px]">{children}</div>
|
||||
<div className={split ? 'flex h-full' : 'flex h-full flex-col'}>
|
||||
<div
|
||||
ref={body}
|
||||
onScroll={split ? undefined : place}
|
||||
className={
|
||||
split
|
||||
? 'contents'
|
||||
: `min-h-0 flex-1 overflow-x-hidden overflow-y-auto ${TAB_INSETS} wide:[&>*]:mx-auto wide:[&>*]:max-w-[760px]`
|
||||
}
|
||||
>
|
||||
<div
|
||||
role={split ? 'complementary' : undefined}
|
||||
aria-label={split ? asideLabel : undefined}
|
||||
className={split ? `m-pane-aside min-h-0 flex-none overflow-y-auto overflow-x-hidden ps-6 pe-3 ${PANE_INSETS}` : undefined}
|
||||
>
|
||||
{aside}
|
||||
</div>
|
||||
<div
|
||||
ref={list}
|
||||
onScroll={split ? place : undefined}
|
||||
className={split ? `min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-6 ps-3 ${PANE_INSETS}` : undefined}
|
||||
>
|
||||
<div className={split ? 'mx-auto max-w-[760px]' : undefined}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
|
||||
@@ -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(
|
||||
<CardGrid>
|
||||
<div>a</div>
|
||||
</CardGrid>
|
||||
);
|
||||
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', () => {
|
||||
</TabPanes>
|
||||
);
|
||||
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 = () => (
|
||||
<TabPanes aside={<input aria-label="budget note" />} asideLabel="Budget">
|
||||
<input aria-label="search" />
|
||||
</TabPanes>
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
Reference in New Issue
Block a user