From 0eac3a9f49d271cb1721ba822c3ebd5f5b11d239 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 09:34:55 +0100 Subject: [PATCH] feat(planner): fold the place inspector to its head and keep the place selected (#3039) On a landscape phone or a small tablet the inspector covered most of the map, and the only way to clear it was closing it, which deselected the place. A chevron in the head now folds the card to the avatar and name; the next place opens in full. The head band moves to PlaceInspectorHead so the inspector stays inside its size budget. --- client/scripts/size-baseline.json | 2 +- .../Planner/PlaceInspector.test.tsx | 40 +++ .../src/components/Planner/PlaceInspector.tsx | 172 +----------- .../components/Planner/PlaceInspectorHead.tsx | 247 ++++++++++++++++++ client/src/pages/TripPlannerPage.tsx | 2 +- wiki/Places-and-Search.md | 2 +- 6 files changed, 304 insertions(+), 161 deletions(-) create mode 100644 client/src/components/Planner/PlaceInspectorHead.tsx diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 340963a11..31fb619e0 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -12,7 +12,7 @@ "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2910, "src/components/Planner/PlaceFormModal.test.tsx": 2836, - "src/components/Planner/PlaceInspector.tsx": 1287, + "src/components/Planner/PlaceInspector.tsx": 1129, "src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index f360162e0..3d842c475 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -1831,3 +1831,43 @@ describe('PlaceInspector blur booking codes (#2457)', () => { expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false); }); }); + +// ── Folding the card over the map (#3039) ───────────────────────────────────── + +describe('PlaceInspector folded to its head (#3039)', () => { + it('FE-PLANNER-INSPECTOR-112: Collapse folds the card to its name and keeps the place open; Expand unfolds it', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const { container } = render(); + + await user.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.getByText('Eiffel Tower')).toBeInTheDocument(); + expect(screen.queryByText('Champ de Mars, Paris')).toBeNull(); + // Hidden, not unmounted: the body and its plugin frames keep their state. + expect(screen.getByTestId('inspector-scroll')).toHaveClass('hidden'); + expect(container.querySelector('footer')).toHaveClass('hidden'); + expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute('aria-expanded', 'false'); + expect(onClose).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Expand' })); + + expect(screen.getByText('Champ de Mars, Paris')).toBeInTheDocument(); + expect(screen.getByTestId('inspector-scroll')).toHaveClass('flex'); + expect(container.querySelector('footer')).toHaveClass('flex'); + }); + + it('FE-PLANNER-INSPECTOR-113: the next place opens in full, and a card that may not fold offers no button', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole('button', { name: 'Collapse' })); + + const louvre = buildPlace({ id: 2, name: 'Louvre', address: 'Rue de Rivoli, Paris', lat: 48.86, lng: 2.34 }); + rerender(); + expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute('aria-expanded', 'true'); + + rerender(); + expect(screen.queryByRole('button', { name: 'Collapse' })).toBeNull(); + }); +}); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index dc1e87eb5..ec8e340e6 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -6,10 +6,8 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { markdownLinkComponents } from '../shared/markdownLink' -import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react' +import { Clock, MapPin, ExternalLink, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react' import { hoursLines, periodsFromWeek } from './placeHours' -import PlaceAvatar from '../shared/PlaceAvatar' -import PlacePhotoGallery from './PlacePhotoGallery' import { BlurredCode } from '../shared/BookingCode' import PlaceRating from '../shared/StarRating' import TrackColorPicker from '../shared/TrackColorPicker' @@ -23,13 +21,11 @@ import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' -import DawarichIcon from '../shared/DawarichIcon' import { Tooltip } from '../shared/Tooltip' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { usePluginStore } from '../../store/pluginStore' import PluginFrame from '../Plugins/PluginFrame' -import AssistantAskButton from '../Assistant/AssistantAskButton' import type { Place, Category, Day, Reservation, TripFile, AssignmentsMap, DistanceUnit } from '../../types' import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters' @@ -44,10 +40,11 @@ import { resolveOpenNow, resolvePlaceTimeZone, getWeekdayIndex, type OpeningPeri import { convertHoursLine } from './placeHoursFormat' import type { EndDayControlProps } from '../Roadtrip/EndDayControl' import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls' -import { DialogButton, DialogSection, DeleteButton, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { DialogButton, DialogSection, DeleteButton, FooterSpacer, fs } from '../shared/DialogShell' import { BOX, Field, TypeTile, toneOf, toneTint, useOpenFile } from './bookings/bookingParts' import { parseMeta } from './bookings/bookingsModel' -import { SoftPill, TimePill, tintOf } from './planParts' +import { SoftPill } from './planParts' +import { InspectorHead } from './PlaceInspectorHead' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' import { participantsWith, participantsWithout, splitParticipants, usePlaceFileUpload } from './usePlaceActions' @@ -188,6 +185,8 @@ interface PlaceInspectorProps extends MapCardPlacement { onUpdatePlace?: (placeId: number, data: Partial) => void /** Cast/clear the current user's star vote (#1435); enables the rating row. */ onRate?: (placeId: number, rating: number | null) => Promise | void + /** The card over the map folds to its head (#3039); the phone's modal sheet does not. */ + collapsible?: boolean // ── Collection-mode props ── collectionStatus?: CollectionStatus onCopyToTrip?: () => void @@ -195,9 +194,6 @@ interface PlaceInspectorProps extends MapCardPlacement { onRemoveFromList?: () => void } -/** The small pill of the head band, the size of a SoftPill but raised on the tint. */ -const HEAD_PILL = 'inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-card px-2 py-[2px] font-semibold text-content shadow-sm' - /** * The card a selected place opens over the map, in the planner's dialog * language: a head band tinted by its category with the avatar, the name, the @@ -209,7 +205,7 @@ export default function PlaceInspector({ assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking, onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate, - leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef, + leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef, collapsible, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, }: PlaceInspectorProps) { // Editing the place is a place right. The planner hands the handlers over regardless, and a member @@ -256,6 +252,8 @@ export default function PlaceInspector({ const saveVersion = useSaveToCollectionStore(s => s.version) const [savedInCollection, setSavedInCollection] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false) + // Folded by place, so the next place selected opens in full. + const [collapsedPlaceId, setCollapsedPlaceId] = useState(null) const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) @@ -364,6 +362,7 @@ export default function PlaceInspector({ const description = place.description || googleDetails?.summary const showRating = (mode === 'trip' && !!onRate) || !!shortReview const mapCard = centredMapCard(leftWidth, rightWidth, { clearPill: clearMapPill }) + const collapsed = collapsedPlaceId === place.id return (
} onUpdatePlace={onUpdatePlace} collapsed={collapsed} + onToggleCollapsed={collapsible ? () => setCollapsedPlaceId(collapsed ? null : place.id) : undefined} onClose={onClose} /> {/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} -
+
{/* Collaborative rating (#1435): every member's own vote, shown as the average, and the first review worth quoting. */} @@ -476,7 +476,7 @@ export default function PlaceInspector({
{/* Footer: the day action and the ways out on the left, delete and edit on the right. */} -