mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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' && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user