From 125649ce1bbfe3b619a8a303dff041eb912c4a3b Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 15:46:09 +0200 Subject: [PATCH] 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 --- .../src/components/Map/centredMapCard.test.ts | 38 +++++++++++++++++++ client/src/components/Map/centredMapCard.ts | 34 +++++++++++++++++ .../src/components/Planner/DayDetailPanel.tsx | 3 +- .../src/components/Planner/PlaceInspector.tsx | 8 ++-- .../src/components/Tours/TourDetailDialog.tsx | 2 +- client/src/components/shared/DetailShell.tsx | 7 +++- .../components/shared/SlidingTabs.test.tsx | 16 +++++++- client/src/components/shared/SlidingTabs.tsx | 15 ++++++++ client/src/pages/TripPlannerPage.tsx | 8 ++-- .../src/pages/TripPlannerPage.wiring.test.tsx | 2 +- 10 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 client/src/components/Map/centredMapCard.test.ts create mode 100644 client/src/components/Map/centredMapCard.ts diff --git a/client/src/components/Map/centredMapCard.test.ts b/client/src/components/Map/centredMapCard.test.ts new file mode 100644 index 000000000..fef18008a --- /dev/null +++ b/client/src/components/Map/centredMapCard.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Map/centredMapCard.ts b/client/src/components/Map/centredMapCard.ts new file mode 100644 index 000000000..c47f1e634 --- /dev/null +++ b/client/src/components/Map/centredMapCard.ts @@ -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))` }; +} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 300f213b6..5135dfae8 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -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 ( -
+
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 2cdfaa131..47bc5a3e4 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -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 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)', }} diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 6fe96e625..55143b59c 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -267,7 +267,7 @@ export default function TourDetailDialog({ return ( - {source === 'gpx' && ( diff --git a/client/src/components/shared/DetailShell.tsx b/client/src/components/shared/DetailShell.tsx index 7dadde8fa..a5ead6768 100644 --- a/client/src/components/shared/DetailShell.tsx +++ b/client/src/components/shared/DetailShell.tsx @@ -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)', }} diff --git a/client/src/components/shared/SlidingTabs.test.tsx b/client/src/components/shared/SlidingTabs.test.tsx index edbe457bb..b23886b5d 100644 --- a/client/src/components/shared/SlidingTabs.test.tsx +++ b/client/src/components/shared/SlidingTabs.test.tsx @@ -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(); + 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(); + expect(container.textContent).toBe('Lists'); + expect(container.querySelector('[class~="xl:hidden"]')).toBeNull(); + }); +}); diff --git a/client/src/components/shared/SlidingTabs.tsx b/client/src/components/shared/SlidingTabs.tsx index c737b9f63..5489aef74 100644 --- a/client/src/components/shared/SlidingTabs.tsx +++ b/client/src/components/shared/SlidingTabs.tsx @@ -162,4 +162,19 @@ export function SlidingTabs({ ) } +/** + * 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 {label} + return ( + <> + {short} + {label} + + ) +} + export default SlidingTabs diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 0ad8922cc..c7732ee25 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 { ({ id: tab.id, - label: {tab.shortLabel || tab.label}, - // The visible label is abbreviated on purpose; the full name goes to - // assistive tech only, so nothing repeats itself under the cursor. + label: , + // 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 /> )} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 1ae2aebf1..d54659537 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -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'),