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