mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke
This commit is contained in:
@@ -90,7 +90,7 @@ describe('JourneyMapPickCard', () => {
|
||||
it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => {
|
||||
const onWriteAbout = vi.fn();
|
||||
const { unmount } = render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New entry' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New Entry' }));
|
||||
expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' }));
|
||||
unmount();
|
||||
|
||||
@@ -103,6 +103,6 @@ describe('JourneyMapPickCard', () => {
|
||||
onWriteAbout={onWriteAbout}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'New entry' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher fitting its map', () => {
|
||||
/** A parent of `room` px, and the pill reporting `withWords` px while its words show. */
|
||||
function renderIn(room: { width: number }, withWords: number) {
|
||||
const host = document.createElement('div')
|
||||
Object.defineProperty(host, 'clientWidth', { get: () => room.width })
|
||||
document.body.appendChild(host)
|
||||
const view = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />, { container: host })
|
||||
const pill = () => screen.getByRole('group')
|
||||
Object.defineProperty(pill(), 'scrollWidth', {
|
||||
get: () => (pill().dataset.layout === 'labels' ? withWords : 200),
|
||||
})
|
||||
return { ...view, pill }
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => {
|
||||
const room = { width: 900 }
|
||||
const { pill, rerender } = renderIn(room, 600)
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
expect(screen.getByText('Topo')).toBeInTheDocument()
|
||||
|
||||
room.width = 500
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
|
||||
|
||||
room.width = 599
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('icons')
|
||||
room.width = 600
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||
expect(pill().dataset.layout).toBe('labels')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
|
||||
expect(screen.getByRole('group').style.flexDirection).toBe('column')
|
||||
expect(screen.queryByText('Topo')).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
|
||||
expect(screen.getByRole('group').dataset.layout).toBe('labels')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ComponentType, CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
import { useLabelFit } from './useLabelFit'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
@@ -31,6 +32,9 @@ export interface TourLayerOffer {
|
||||
|
||||
/** Distance from the map's left edge (plus any side panel) to the switcher. */
|
||||
export const MAP_LAYER_SWITCHER_INSET = 20
|
||||
|
||||
/** What the switcher row leaves free at the map's trailing edge: the locate button (20px off the edge, 44px wide) and a gap. */
|
||||
export const LOCATE_BUTTON_ROOM = 76
|
||||
/**
|
||||
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
|
||||
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
|
||||
@@ -257,11 +261,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
)
|
||||
}
|
||||
|
||||
/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */
|
||||
function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) {
|
||||
const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical)
|
||||
const compact = vertical || (compactProp ?? iconsOnly)
|
||||
const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels'
|
||||
return { attachFit: attach, compact, layout }
|
||||
}
|
||||
|
||||
function shellStyle(vertical: boolean): CSSProperties {
|
||||
return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 }
|
||||
}
|
||||
|
||||
function Divider({ vertical }: { vertical: boolean }) {
|
||||
return <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||
* width (the 3D map's, beside the flyover dock).
|
||||
* width (the 3D map's, beside the flyover dock). Left out, the control goes to
|
||||
* its icons by itself once its words no longer fit the room its parent gives
|
||||
* it (a narrow map, such as the iPad's beside the tour pane).
|
||||
* `vertical` stands it on the map's side, icons only (the 3D map, where its
|
||||
* bottom row has no room left for it).
|
||||
*/
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
@@ -271,7 +295,8 @@ export function TourMapLayerSwitcher({
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
compact: compactProp,
|
||||
vertical = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
@@ -285,15 +310,23 @@ export function TourMapLayerSwitcher({
|
||||
/** Offers the "set points" switch, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool
|
||||
compact?: boolean
|
||||
vertical?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
<div
|
||||
ref={attachFit}
|
||||
style={shellStyle(vertical)}
|
||||
role="group"
|
||||
aria-label={t('settings.mapStyle')}
|
||||
data-layout={layout}
|
||||
>
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = t(labelKey)
|
||||
@@ -321,7 +354,7 @@ export function TourMapLayerSwitcher({
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</span>
|
||||
)}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
|
||||
@@ -73,7 +73,7 @@ import VectorBasemap from './VectorBasemap'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, LOCATE_BUTTON_ROOM, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
@@ -1126,7 +1126,7 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, right: rightWidth + LOCATE_BUTTON_ROOM, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applyBaseLayer } from './glRasterLayers'
|
||||
import { reportGlViewport } from './glViewport'
|
||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, LOCATE_BUTTON_ROOM, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
@@ -2060,8 +2060,8 @@ export function MapViewGL({
|
||||
card too, and used to send the pill to the top of the map for a corner it never
|
||||
covers. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, right: rightWidth + LOCATE_BUTTON_ROOM, zIndex: 1000, pointerEvents: 'none',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8,
|
||||
bottom: isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* change, and what is left behind on teardown.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import type * as L from 'leaflet'
|
||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
@@ -47,6 +47,8 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const gl = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: vi.fn(),
|
||||
isStyleLoaded: vi.fn(() => false),
|
||||
getStyle: vi.fn(() => ({
|
||||
layers: [
|
||||
@@ -73,6 +75,8 @@ type Spy = ReturnType<typeof vi.fn>
|
||||
interface MockGl {
|
||||
setStyle: Spy
|
||||
on: Spy
|
||||
once: Spy
|
||||
off: Spy
|
||||
isStyleLoaded: Spy
|
||||
getStyle: Spy
|
||||
setLayoutProperty: Spy
|
||||
@@ -124,6 +128,31 @@ describe('VectorBasemap', () => {
|
||||
expect(maplibreGL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-020: a style that errors before it loads gives way to raster tiles; later errors do not', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const { findByTestId, queryByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
const { layer, gl } = lastLayer()
|
||||
const onError = gl.on.mock.calls.find(([name]) => name === 'error')![1] as (e: { error?: unknown }) => void
|
||||
expect(gl.once).toHaveBeenCalledWith('style.load', expect.any(Function))
|
||||
expect(queryByTestId('raster-basemap')).toBeNull()
|
||||
|
||||
act(() => onError({ error: new Error('trek-offline failed') }))
|
||||
expect(await findByTestId('raster-basemap')).toBeInTheDocument()
|
||||
expect(layer.remove).toHaveBeenCalled()
|
||||
expect(gl.off).toHaveBeenCalledWith('error', onError)
|
||||
|
||||
// A second map whose style did load keeps its vector basemap through a tile error.
|
||||
render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(2))
|
||||
const second = lastLayer()
|
||||
const secondError = second.gl.on.mock.calls.find(([name]) => name === 'error')![1] as (e: { error?: unknown }) => void
|
||||
;(second.gl.once.mock.calls.find(([name]) => name === 'style.load')![1] as () => void)()
|
||||
act(() => secondError({ error: new Error('one tile') }))
|
||||
expect(second.layer.remove).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
|
||||
@@ -107,6 +107,26 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `fail` once when the layer's style errors before it ever loaded. Errors
|
||||
* after that (a tile here and there) are the map's ordinary weather and leave it be.
|
||||
*/
|
||||
function onStyleFailure(layer: GlLeafletLayer, fail: () => void): void {
|
||||
const gl = layer.getMaplibreMap?.()
|
||||
if (!gl) return
|
||||
let loaded = false
|
||||
gl.once('style.load', () => {
|
||||
loaded = true
|
||||
})
|
||||
const onError = (event: { error?: unknown }) => {
|
||||
if (loaded) return
|
||||
gl.off('error', onError)
|
||||
console.warn('[basemap] the vector style did not load, drawing raster tiles', event.error)
|
||||
fail()
|
||||
}
|
||||
gl.on('error', onError)
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
||||
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
||||
@@ -201,6 +221,14 @@ export function VectorBasemap({ style }: { style: string }) {
|
||||
return
|
||||
}
|
||||
layerRef.current = layer
|
||||
// A style that never loads (its server, or the iOS app's offline protocol
|
||||
// failing) would leave the map white under its markers: raster instead.
|
||||
onStyleFailure(layer, () => {
|
||||
if (cancelled || layerRef.current !== layer) return
|
||||
detachBasemapLayer(layer)
|
||||
layerRef.current = null
|
||||
setGlUsable(false)
|
||||
})
|
||||
})()
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Whether a control row keeps its words or goes to its icons: it keeps them
|
||||
* while it fits the room its parent gives it (a map's bottom edge, between the
|
||||
* panels and the locate button), and goes back to them once the room has grown
|
||||
* to what the words needed, so it never flips back and forth at one width.
|
||||
*
|
||||
* `enabled` false leaves the words on and measures nothing.
|
||||
*/
|
||||
export function useLabelFit(enabled: boolean) {
|
||||
const [node, setNode] = useState<HTMLElement | null>(null);
|
||||
const [iconsOnly, setIconsOnly] = useState(false);
|
||||
// The width the row needed with its words, once it had to give them up.
|
||||
const needed = useRef<number | null>(null);
|
||||
|
||||
const check = useCallback(() => {
|
||||
const room = node?.parentElement?.clientWidth;
|
||||
if (!node || !room) return;
|
||||
setIconsOnly((current) => {
|
||||
if (current) return needed.current !== null && room >= needed.current ? false : current;
|
||||
if (node.scrollWidth > room + 1) {
|
||||
needed.current = node.scrollWidth;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [node]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (enabled) check();
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const parent = node?.parentElement;
|
||||
if (!enabled || !parent || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => check());
|
||||
observer.observe(parent);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, node, check]);
|
||||
|
||||
return { attach: setNode, iconsOnly: enabled && iconsOnly };
|
||||
}
|
||||
@@ -136,7 +136,7 @@ function NeutralRail({
|
||||
canEdit: boolean
|
||||
onOpenWanderer?: () => void
|
||||
}) {
|
||||
const railHead = useRailHeadProps()
|
||||
const railHead = useRailHeadProps({ bare: true })
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
|
||||
|
||||
@@ -23,4 +23,21 @@ describe('rail heads', () => {
|
||||
expect(head.className).not.toContain('border-b');
|
||||
expect(head.getAttribute('style')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-RAILHEAD-002: a head of buttons only stands without a band in the touch tab, and keeps its strip on the desktop', () => {
|
||||
function Bare() {
|
||||
return <div data-testid="bare" {...useRailHeadProps({ bare: true })} />;
|
||||
}
|
||||
const { unmount } = render(<Bare />);
|
||||
expect(screen.getByTestId('bare').className).toBe(RAIL_HEAD);
|
||||
unmount();
|
||||
render(
|
||||
<FloatingRailHeads value>
|
||||
<Bare />
|
||||
</FloatingRailHeads>
|
||||
);
|
||||
const bare = screen.getByTestId('bare');
|
||||
expect(bare.className).not.toMatch(/border|rounded|bg-/);
|
||||
expect(bare.getAttribute('style')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,17 @@ const FLOATING_RAIL_HEAD =
|
||||
*/
|
||||
export const FloatingRailHeads = createContext(false);
|
||||
|
||||
/** The class and style of a rail's head band, as the surrounding tab wants it. */
|
||||
export function useRailHeadProps(): { className: string; style?: { background: string } } {
|
||||
return use(FloatingRailHeads)
|
||||
? { className: FLOATING_RAIL_HEAD }
|
||||
: { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } };
|
||||
/** A head that holds only buttons: in the touch tab they stand on their own, without a band. */
|
||||
const BARE_RAIL_HEAD = 'mx-3 mt-3 flex flex-none items-start gap-2';
|
||||
|
||||
/**
|
||||
* The class and style of a rail's head band, as the surrounding tab wants it.
|
||||
* `bare`: the head holds only buttons (the editor with nothing open).
|
||||
*/
|
||||
export function useRailHeadProps({ bare = false }: { bare?: boolean } = {}): {
|
||||
className: string;
|
||||
style?: { background: string };
|
||||
} {
|
||||
if (!use(FloatingRailHeads)) return { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } };
|
||||
return { className: bare ? BARE_RAIL_HEAD : FLOATING_RAIL_HEAD };
|
||||
}
|
||||
|
||||
@@ -88,7 +88,20 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
terrain.canReplay && !props.water ? (
|
||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
||||
) : null;
|
||||
const legendAbove = fit === 'stacked';
|
||||
const legendAbove = fit === 'stacked' || fit === 'side';
|
||||
const layerSwitcher = (vertical: boolean) => (
|
||||
<TourMapLayerSwitcher
|
||||
active={baseLayer}
|
||||
onChange={onBaseLayerChange}
|
||||
terrainActive
|
||||
onToggleTerrain={onExit}
|
||||
ferrataLayer={props.ferrataLayer}
|
||||
placeTool={props.placeTool}
|
||||
layers={props.tourLayers}
|
||||
compact={fit !== 'full'}
|
||||
vertical={vertical}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
|
||||
@@ -112,21 +125,11 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
|
||||
}}
|
||||
>
|
||||
{fit === 'side' && layerSwitcher(true)}
|
||||
<BaseMapCredit layer={baseLayer} />
|
||||
{legendAbove && legend}
|
||||
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
|
||||
<div className="flex-none">
|
||||
<TourMapLayerSwitcher
|
||||
active={baseLayer}
|
||||
onChange={onBaseLayerChange}
|
||||
terrainActive
|
||||
onToggleTerrain={onExit}
|
||||
ferrataLayer={props.ferrataLayer}
|
||||
placeTool={props.placeTool}
|
||||
layers={props.tourLayers}
|
||||
compact={fit !== 'full'}
|
||||
/>
|
||||
</div>
|
||||
{fit !== 'side' && <div className="flex-none">{layerSwitcher(false)}</div>}
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<span />
|
||||
{terrain.canReplay ? (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-002: the 3D map's bottom row steps down to fit between the panels.
|
||||
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-003: the 3D map's bottom row steps down to fit between the panels.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
|
||||
|
||||
/** What the row's content needs at each step, in pixels. */
|
||||
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400 };
|
||||
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400, side: 260 };
|
||||
|
||||
function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
@@ -49,4 +49,21 @@ describe('useControlRowFit', () => {
|
||||
rerender();
|
||||
expect(result.current.fit).toBe('full');
|
||||
});
|
||||
|
||||
it('FE-TERRAIN-FIT-003: where even the stacked row overflows, the layer pill goes to the side, and comes back with the room', () => {
|
||||
const room = { width: 300 };
|
||||
const { result, rerender } = renderHook(() => useControlRowFit());
|
||||
const row = fakeRow(room, () => result.current.fit);
|
||||
act(() => result.current.attachRow(row));
|
||||
rerender();
|
||||
rerender();
|
||||
expect(result.current.fit).toBe('side');
|
||||
|
||||
room.width = 399;
|
||||
rerender();
|
||||
expect(result.current.fit).toBe('side');
|
||||
room.width = 400;
|
||||
rerender();
|
||||
expect(result.current.fit).toBe('stacked');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* How the 3D map's bottom row fits between the side panels: `full` with every
|
||||
* label, `compact` with the layer pill on its icons, and `stacked` with the
|
||||
* grade legend on a row of its own above the dock as well.
|
||||
* label, `compact` with the layer pill on its icons, `stacked` with the grade
|
||||
* legend on a row of its own above the dock as well, and `side` with the layer
|
||||
* pill standing on the map's trailing edge, leaving the row to the dock (a map
|
||||
* as narrow as the iPad's beside the tour pane).
|
||||
*/
|
||||
export type ControlRowFit = 'full' | 'compact' | 'stacked';
|
||||
export type ControlRowFit = 'full' | 'compact' | 'stacked' | 'side';
|
||||
|
||||
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked'];
|
||||
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked', 'side'];
|
||||
|
||||
/**
|
||||
* Steps the row down while its content is wider than the room between the
|
||||
|
||||
Reference in New Issue
Block a user