fix(planner): cards over the map stay clear of the layer pill, and a wide tab bar shows full tab names

The base-layer pill sits in the map's bottom-left corner on purpose. The
place inspector, a tour's detail and the day panel are centred between the
sidebars and were assumed never to reach that corner, but with both
sidebars open in a 1280 window they do, and the pill covered "Remove from
Day" and Edit. These cards now keep their left edge clear of the pill: a
narrow map moves the card right of it, a wide map keeps it centred as
before. The pill itself does not move, and the phone sheets are unchanged.

The Bookings tab showed "Book" at every width, which read as cut off. The
short form now only shows where the bar is tight; from xl up the tab shows
its full name.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 15:46:09 +02:00
parent cb04ebe6d2
commit 125649ce1b
10 changed files with 119 additions and 14 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { centredMapCardBox } from './centredMapCard';
// Resolves the CSS the helper returns for a given container width, so the
// geometry is checked in pixels rather than as strings.
function resolve(css: string, extent: number): number {
const expr = css
.replace(/100%/g, `${extent}px`)
.replace(/px/g, '')
.replace(/calc/g, '')
.replace(/min\(/g, 'Math.min(')
.replace(/max\(/g, 'Math.max(');
return Function(`return (${expr})`)() as number;
}
describe('centredMapCardBox', () => {
it('keeps the old centred box when there is no pill to clear', () => {
const box = centredMapCardBox(320, 320);
expect(resolve(box.width, 1280)).toBe(608);
expect(resolve(box.left, 1280)).toBe(640);
});
it('a narrow desktop map moves the card off the base-layer pill (#2604)', () => {
const box = centredMapCardBox(350, 310, { clearPill: true });
const width = resolve(box.width, 1280);
const leftEdge = resolve(box.left, 1280) - width / 2;
// The pill spans 20 to 62 px right of the left panel.
expect(leftEdge).toBeGreaterThanOrEqual(350 + 62);
// And the card still stops 16 px short of the right panel.
expect(leftEdge + width).toBeLessThanOrEqual(1280 - 310 - 16);
});
it('a wide map keeps the card exactly centred and 800 wide', () => {
const box = centredMapCardBox(320, 320, { clearPill: true });
expect(resolve(box.width, 1920)).toBe(800);
expect(resolve(box.left, 1920)).toBe(960);
});
});
@@ -0,0 +1,34 @@
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher';
/** The card's gap to the side panels where nothing else sits at the map's edge. */
const EDGE_GAP = 16;
/** How far the card keeps from the left panel, so it ends short of the base-layer pill. */
const PILL_CLEARANCE = MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8;
/**
* Where a card opened over the map sits (the place inspector, a tour's detail,
* the day panel): centred between the two side panels, at most 800 wide.
*
* On the desktop map the base-layer pill stays put in the bottom-left corner. On
* a map wide enough the centred card never reaches that corner, but on a narrower
* one (both sidebars open in a 1280 window) it ran under the pill, which then
* covered the first button of the card's footer (#2604). With `clearPill` the
* card's left edge never comes closer than the pill: a narrow card moves right of
* it and gives up the few pixels it needs, a wide map keeps it exactly centred.
* The phone shows these cards as sheets with no pill beside them, so it leaves
* `clearPill` off.
*
* `extent` is what the card is placed in: '100%' of the map, or '100vw' for the
* fixed day panel.
*/
export function centredMapCardBox(
leftWidth: number,
rightWidth: number,
{ extent = '100%', clearPill = false }: { extent?: string; clearPill?: boolean } = {}
): { left: string; width: string } {
const free = `(${extent} - ${leftWidth}px - ${rightWidth}px)`;
const centre = `calc(${leftWidth}px + ${free} / 2)`;
if (!clearPill) return { left: centre, width: `min(800px, calc(${free} - ${2 * EDGE_GAP}px))` };
const width = `min(800px, calc(${free} - ${PILL_CLEARANCE + EDGE_GAP}px))`;
return { width, left: `max(${centre}, calc(${leftWidth}px + ${PILL_CLEARANCE}px + ${width} / 2))` };
}
@@ -26,6 +26,7 @@ import { BOX, Eyebrow, Field, RoundAction, TypeTile, toneOf, toneColor, toneTint
import { SoftPill, TimePill, tintOf } from './planParts'
import { typeInfo } from './bookings/bookingsModel'
import { weatherIconFor } from '../Weather/weatherIcons'
import { centredMapCardBox } from '../Map/centredMapCard'
function WIcon({ main, size = 14 }: { main: string; size?: number }) {
const Icon = weatherIconFor(main)
@@ -162,7 +163,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const removeAccommodation = (acc: Accommodation) => { handleRemoveAccommodation(acc).catch(showError) }
return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), fontFamily: 'var(--font-system)' }}>
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', transform: 'translateX(-50%)', ...centredMapCardBox(leftWidth, rightWidth, { extent: '100vw', clearPill: !mobile }), ...(mobile ? { zIndex: 10000 } : null), fontFamily: 'var(--font-system)' }}>
<div ref={cardRef}
className="flex flex-col overflow-hidden rounded-[20px] border border-edge-faint bg-surface-elevated shadow-popover backdrop-blur-[40px] backdrop-saturate-[1.8]"
style={{ maxHeight: collapsed ? 'none' : '60vh' }}>
@@ -38,6 +38,7 @@ import { formatDistance, formatElevation } from '../../utils/units'
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getPlaceBookings } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { centredMapCardBox } from '../Map/centredMapCard'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex, type OpeningPeriod } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
@@ -199,6 +200,7 @@ interface PlaceInspectorProps {
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
rightWidth?: number
clearMapPill?: boolean // over the desktop map, beside the base-layer pill
// ── Collection-mode props ──
collectionStatus?: CollectionStatus
onCopyToTrip?: () => void
@@ -220,7 +222,7 @@ export default function PlaceInspector({
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate,
leftWidth = 0, rightWidth = 0,
leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over
@@ -381,9 +383,7 @@ export default function PlaceInspector({
style={{
position: 'absolute',
bottom: 20,
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
transform: 'translateX(-50%)',
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
...centredMapCardBox(leftWidth, rightWidth, { clearPill: clearMapPill }), transform: 'translateX(-50%)',
zIndex: 50,
fontFamily: 'var(--font-system)',
}}
@@ -267,7 +267,7 @@ export default function TourDetailDialog({
</>
return (
<DetailShell header={header} footer={footer} onClose={onClose} closeLabel={t('common.close')} leftWidth={leftWidth} rightWidth={rightWidth}
<DetailShell header={header} footer={footer} onClose={onClose} closeLabel={t('common.close')} leftWidth={leftWidth} rightWidth={rightWidth} clearMapPill={desktopNonModal}
tint={tintOf(trackColor)}
closeButtonClassName="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2">
{source === 'gpx' && (
+5 -2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { X } from 'lucide-react'
import { NEUTRAL_TINT } from './DialogShell'
import { Tooltip } from './Tooltip'
import { centredMapCardBox } from '../Map/centredMapCard'
interface DetailShellProps {
header: ReactNode
@@ -11,6 +12,8 @@ interface DetailShellProps {
closeLabel: string
leftWidth?: number
rightWidth?: number
/** Over the desktop map: keep clear of the base-layer pill in its bottom-left corner. */
clearMapPill?: boolean
/** The head band's background; the neutral accent wash when left out. */
tint?: string
closeButtonClassName?: string
@@ -30,6 +33,7 @@ export default function DetailShell({
closeLabel,
leftWidth = 0,
rightWidth = 0,
clearMapPill = false,
tint = NEUTRAL_TINT,
closeButtonClassName = '',
testId,
@@ -39,9 +43,8 @@ export default function DetailShell({
style={{
position: 'absolute',
bottom: 20,
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
...centredMapCardBox(leftWidth, rightWidth, { clearPill: clearMapPill }),
transform: 'translateX(-50%)',
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
zIndex: 50,
fontFamily: 'var(--font-system)',
}}
@@ -1,5 +1,5 @@
import { render, waitFor, act } from '../../../tests/helpers/render';
import SlidingTabs from './SlidingTabs';
import SlidingTabs, { TabLabel } from './SlidingTabs';
const TABS = [
{ id: 'a', label: 'Alpha' },
@@ -106,3 +106,17 @@ describe('SlidingTabs', () => {
expect(pill()!.style.width).toBe('90px');
});
});
describe('TabLabel', () => {
it('shows the full name in a wide window and the short form only where the bar is tight (#2604)', () => {
const { container } = render(<TabLabel label="Bookings" short="Book" />);
expect(container.querySelector('[class~="xl:inline"]')).toHaveTextContent('Bookings');
expect(container.querySelector('[class~="xl:hidden"]')).toHaveTextContent('Book');
});
it('a label without a short form has nothing to swap', () => {
const { container } = render(<TabLabel label="Lists" short="Lists" />);
expect(container.textContent).toBe('Lists');
expect(container.querySelector('[class~="xl:hidden"]')).toBeNull();
});
});
@@ -162,4 +162,19 @@ export function SlidingTabs<T extends string>({
)
}
/**
* A tab label that also has a short form: icon only on a phone, the short form
* where the bar is tight, the full name from xl up. A wide window has the room,
* and an abbreviation there ("Book" for Bookings) read as cut off (#2604).
*/
export function TabLabel({ label, short }: { label: string; short?: string }): React.ReactElement {
if (!short || short === label) return <span className="hidden sm:inline">{label}</span>
return (
<>
<span className="hidden sm:inline xl:hidden">{short}</span>
<span className="hidden xl:inline">{label}</span>
</>
)
}
export default SlidingTabs
+4 -4
View File
@@ -16,7 +16,7 @@ import PlaceInspector from '../components/Planner/PlaceInspector'
import DayDetailPanel from '../components/Planner/DayDetailPanel'
import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
import SlidingTabs from '../components/shared/SlidingTabs'
import SlidingTabs, { TabLabel } from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import TransitJourneyModal from '../components/Planner/TransitJourneyModal'
@@ -261,9 +261,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<SlidingTabs
tabs={TRIP_TABS.map(tab => ({
id: tab.id,
label: <span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>,
// The visible label is abbreviated on purpose; the full name goes to
// assistive tech only, so nothing repeats itself under the cursor.
label: <TabLabel label={tab.label} short={tab.shortLabel} />,
// An abbreviated label gives its full name to assistive tech, not to a tooltip under the cursor.
ariaLabel: tab.label,
icon: tab.icon,
}))}
@@ -563,6 +562,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onDelete={() => handleDeletePlace(selectedPlace.id)}
leftWidth={isMobile ? 0 : leftPanelPx}
rightWidth={isMobile ? 0 : rightPanelPx}
clearMapPill
/>
)}
@@ -80,7 +80,7 @@ vi.mock('../components/Map/usePoiExplore', () => ({
}))
vi.mock('../components/Layout/Navbar', () => ({ default: stub('navbar', 'navbar') }))
vi.mock('../components/shared/SlidingTabs', () => ({ default: stub('tabs', 'sliding-tabs') }))
vi.mock('../components/shared/SlidingTabs', () => ({ default: stub('tabs', 'sliding-tabs'), TabLabel: () => null }))
vi.mock('../components/shared/TripLoadingSplash', () => ({
default: ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'splash', role: 'status' }, title ?? 'TREK'),