- {!rightCollapsed && (
+ {!rightHidden && !narrowPanels && (
{ setShowDayDetail(null); handleSelectDay(null) }}
onAccommodationChange={loadAccommodations}
- leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
- rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
+ leftWidth={isMobile ? 0 : leftPanelPx}
+ rightWidth={isMobile ? 0 : rightPanelPx}
collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
mobile={isMobile}
@@ -659,8 +679,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
- leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
- rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
+ leftWidth={isMobile ? 0 : leftPanelPx}
+ rightWidth={isMobile ? 0 : rightPanelPx}
/>
)}
diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx
index 69861f580..6430106d4 100644
--- a/client/src/pages/TripPlannerPage.wiring.test.tsx
+++ b/client/src/pages/TripPlannerPage.wiring.test.tsx
@@ -165,6 +165,11 @@ function baseState(): HookState {
rightCollapsed: false,
setLeftCollapsed: vi.fn(),
setRightCollapsed: vi.fn(),
+ leftHidden: false,
+ rightHidden: false,
+ toggleLeft: vi.fn(),
+ toggleRight: vi.fn(),
+ narrowPanels: false,
startResizeLeft: vi.fn(),
startResizeRight: vi.fn(),
selectedPlaceId: null,
@@ -371,7 +376,7 @@ describe('TripPlannerPage — plan tab', () => {
})
it('FE-PAGE-TPW-009: collapsed panels report a zero width to the map', () => {
- renderPage({ leftCollapsed: true, rightCollapsed: true })
+ renderPage({ leftHidden: true, rightHidden: true })
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
@@ -410,19 +415,42 @@ describe('TripPlannerPage — plan tab', () => {
const [leftBtn, rightBtn] = screen.getAllByRole('button')
fireEvent.click(leftBtn)
- expect(updaterOf(hookState.setLeftCollapsed)(false)).toBe(true)
+ expect(hookState.toggleLeft).toHaveBeenCalled()
fireEvent.mouseEnter(leftBtn)
fireEvent.mouseLeave(leftBtn)
fireEvent.click(rightBtn)
- expect(updaterOf(hookState.setRightCollapsed)(false)).toBe(true)
+ expect(hookState.toggleRight).toHaveBeenCalled()
fireEvent.mouseEnter(rightBtn)
fireEvent.mouseLeave(rightBtn)
})
+ // The tabs are the only way back to a panel on a touch device, and they used to
+ // announce themselves with nothing but a hover colour (#2247).
+ it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
+ renderPage({ leftHidden: true, rightHidden: false })
+ const [leftBtn, rightBtn] = screen.getAllByRole('button')
+ expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
+ expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
+ })
+
+ // At a foldable's ~860px the viewport centre sits under the Places panel, so the
+ // floating POI/compass cluster covered both tabs and the Add Place button (#2247).
+ it('FE-PAGE-TPW-013c: the floating map controls centre on the map corridor, not the viewport', () => {
+ const { container } = renderPage({ leftWidth: 340, rightWidth: 300 })
+ const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
+ expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 310px))')
+ })
+
+ it('FE-PAGE-TPW-013d: a hidden panel takes no corridor away from the map controls', () => {
+ const { container } = renderPage({ leftWidth: 340, rightWidth: 300, rightHidden: true })
+ const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
+ expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
+ })
+
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
- renderPage({ leftCollapsed: true, rightCollapsed: true })
+ renderPage({ leftHidden: true, rightHidden: true })
const [leftBtn, rightBtn] = screen.getAllByRole('button')
fireEvent.mouseEnter(leftBtn)
@@ -433,6 +461,13 @@ describe('TripPlannerPage — plan tab', () => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument()
})
+ // Mouse-only handles (mousedown + document mousemove) are inert on a tablet, and
+ // a 4px invisible strip on the panel edge is a trap for a fat finger (#2247).
+ it('FE-PAGE-TPW-015b: the narrow layout drops the mouse-only resize handles', () => {
+ const { container } = renderPage({ narrowPanels: true })
+ expect(container.querySelectorAll('div[style*="col-resize"]')).toHaveLength(0)
+ })
+
it('FE-PAGE-TPW-015: the resize handles start a drag on mouse-down and highlight on hover', () => {
const { container } = renderPage()
const handles = container.querySelectorAll('div[style*="col-resize"]')
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index a6b538fad..4ed67dcad 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -194,7 +194,11 @@ export function useTripPlanner() {
if (activeTab === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
if (activeTab === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
}, [tripId])
- const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
+ const {
+ leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
+ leftHidden, rightHidden, toggleLeft, toggleRight, narrow: narrowPanels,
+ startResizeLeft, startResizeRight,
+ } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const [showDayDetail, setShowDayDetail] = useState(null)
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
@@ -1081,7 +1085,9 @@ export function useTripPlanner() {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
- leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
+ leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
+ leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
+ startResizeLeft, startResizeRight,
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
diff --git a/client/src/utils/mapViewport.test.ts b/client/src/utils/mapViewport.test.ts
index bcd8d0b36..fab9a2cbb 100644
--- a/client/src/utils/mapViewport.test.ts
+++ b/client/src/utils/mapViewport.test.ts
@@ -223,4 +223,43 @@ describe('computeMapViewport', () => {
expect(x).toBeLessThanOrEqual(VIEW.width)
}
})
+
+ // Two 300px planner panels on an 860px foldable left the framing window 140px
+ // wide, and the camera then opened on half of East Asia instead of the trip
+ // (#2247). The layout fix stops it happening; this stops it mattering.
+ it('never frames the camera for less than a third of the box, however wide the padding', () => {
+ const sane = computeMapViewport([PARIS, LYON], {
+ ...VIEW,
+ tileSize: TILE_SIZE_RASTER,
+ padding: { left: 380, right: 340 },
+ })!
+ const absurd = computeMapViewport([PARIS, LYON], {
+ ...VIEW,
+ tileSize: TILE_SIZE_RASTER,
+ padding: { left: 480, right: 480 },
+ })!
+ // 1000 - 960 = 40px of window would zoom ~2.5 levels further out than the
+ // third the clamp guarantees.
+ const floored = computeMapViewport([PARIS, LYON], {
+ width: VIEW.width / 3,
+ height: VIEW.height,
+ tileSize: TILE_SIZE_RASTER,
+ })!
+ expect(absurd.zoom).toBe(floored.zoom)
+ expect(absurd.zoom).toBeGreaterThan(sane.zoom - 3)
+ })
+
+ it('leaves a sane padding untouched', () => {
+ const withPadding = computeMapViewport([PARIS, LYON], {
+ ...VIEW,
+ tileSize: TILE_SIZE_RASTER,
+ padding: { left: 200, right: 100 },
+ })!
+ const equivalent = computeMapViewport([PARIS, LYON], {
+ width: VIEW.width - 300,
+ height: VIEW.height,
+ tileSize: TILE_SIZE_RASTER,
+ })!
+ expect(withPadding.zoom).toBe(equivalent.zoom)
+ })
})
diff --git a/client/src/utils/mapViewport.ts b/client/src/utils/mapViewport.ts
index 7041e5e8c..b0dce0835 100644
--- a/client/src/utils/mapViewport.ts
+++ b/client/src/utils/mapViewport.ts
@@ -215,8 +215,14 @@ export function computeMapViewport(
const wrapsWorld = options.wrapsWorld ?? isGl
const padding: ViewportPadding = { ...NO_PADDING, ...options.padding }
- const width = Math.max(1, (options.width ?? defaultWidth()) - padding.left - padding.right)
- const height = Math.max(1, (options.height ?? defaultHeight()) - padding.top - padding.bottom)
+ const rawWidth = options.width ?? defaultWidth()
+ const rawHeight = options.height ?? defaultHeight()
+ // Padding wider than the box leaves a sliver, and the camera is then framed for
+ // THAT — a continent instead of the trip. Two 300px planner panels on an 860px
+ // foldable did exactly this (#2247). Never surrender more than two thirds; the
+ // centre offset still uses the real padding, so the framing stays where it was.
+ const width = Math.max(1, rawWidth / 3, rawWidth - padding.left - padding.right)
+ const height = Math.max(1, rawHeight / 3, rawHeight - padding.top - padding.bottom)
const lats = pts.map(p => p[0])
const south = Math.min(...lats)
diff --git a/client/tests/integration/hooks/useResizablePanels.test.ts b/client/tests/integration/hooks/useResizablePanels.test.ts
index b3b085336..89b1064f2 100644
--- a/client/tests/integration/hooks/useResizablePanels.test.ts
+++ b/client/tests/integration/hooks/useResizablePanels.test.ts
@@ -1,14 +1,38 @@
import { renderHook, act } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { useResizablePanels } from '../../../src/hooks/useResizablePanels';
+/**
+ * The suite's jsdom stub answers `matches: false` to everything, so the hook is
+ * in its wide layout unless a test says otherwise. `narrow()` puts it in the
+ * 768-1023 band the foldable lands in (#2247).
+ */
+function setViewport(width: number, narrow: boolean) {
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query.includes('min-width: 768px') ? narrow : false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })) as unknown as typeof window.matchMedia;
+}
+
describe('useResizablePanels', () => {
+ const realMatchMedia = window.matchMedia;
+
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
+ afterEach(() => {
+ window.matchMedia = realMatchMedia;
+ });
+
it('FE-HOOK-PANELS-001: default leftWidth is 340 when localStorage is empty', () => {
const { result } = renderHook(() => useResizablePanels());
expect(result.current.leftWidth).toBe(340);
@@ -165,4 +189,85 @@ describe('useResizablePanels', () => {
expect(result.current.setLeftCollapsed).toBeTypeOf('function');
expect(result.current.setRightCollapsed).toBeTypeOf('function');
});
+
+ // ── The narrow band, 768-1023px (#2247) ──────────────────────────────────
+
+ it('FE-HOOK-PANELS-015: a wide layout shows whatever the collapse flags say', () => {
+ setViewport(1440, false);
+ const { result } = renderHook(() => useResizablePanels());
+ expect(result.current.narrow).toBe(false);
+ expect(result.current.leftHidden).toBe(false);
+ expect(result.current.rightHidden).toBe(false);
+
+ act(() => { result.current.toggleRight(); });
+ expect(result.current.rightHidden).toBe(true);
+ expect(result.current.leftHidden).toBe(false);
+ expect(result.current.rightCollapsed).toBe(true);
+ });
+
+ it('FE-HOOK-PANELS-016: the narrow band opens the day plan and hides the places list', () => {
+ setViewport(860, true);
+ const { result } = renderHook(() => useResizablePanels());
+ expect(result.current.narrow).toBe(true);
+ expect(result.current.leftHidden).toBe(false);
+ expect(result.current.rightHidden).toBe(true);
+ });
+
+ it('FE-HOOK-PANELS-017: opening one panel in the narrow band closes the other', () => {
+ setViewport(860, true);
+ const { result } = renderHook(() => useResizablePanels());
+
+ act(() => { result.current.toggleRight(); });
+ expect(result.current.rightHidden).toBe(false);
+ expect(result.current.leftHidden).toBe(true);
+
+ act(() => { result.current.toggleLeft(); });
+ expect(result.current.leftHidden).toBe(false);
+ expect(result.current.rightHidden).toBe(true);
+ });
+
+ it('FE-HOOK-PANELS-018: tapping the open panel in the narrow band leaves the map alone', () => {
+ setViewport(860, true);
+ const { result } = renderHook(() => useResizablePanels());
+
+ act(() => { result.current.toggleLeft(); });
+ expect(result.current.leftHidden).toBe(true);
+ expect(result.current.rightHidden).toBe(true);
+ });
+
+ // A marker tap calls both setters with false; in the narrow band that used to
+ // re-crowd the screen the user had just cleared.
+ it('FE-HOOK-PANELS-019: reopening both panels does not re-crowd the narrow layout', () => {
+ setViewport(860, true);
+ const { result } = renderHook(() => useResizablePanels());
+
+ act(() => { result.current.toggleRight(); });
+ act(() => { result.current.setLeftCollapsed(false); result.current.setRightCollapsed(false); });
+ expect(result.current.rightHidden).toBe(false);
+ expect(result.current.leftHidden).toBe(true);
+ });
+
+ it('FE-HOOK-PANELS-020: a panel dragged wide on a desktop is clamped so the map keeps 360px', () => {
+ localStorage.setItem('sidebarLeftWidth', '520');
+ setViewport(800, true);
+ const { result } = renderHook(() => useResizablePanels());
+ // 800 - 360 map - 20 margins = 420
+ expect(result.current.leftWidth).toBe(420);
+ // The stored value is untouched, so a wide window gets its 520 back.
+ expect(localStorage.getItem('sidebarLeftWidth')).toBe('520');
+ });
+
+ it('FE-HOOK-PANELS-021: the clamp never squeezes a panel below the drag minimum', () => {
+ localStorage.setItem('sidebarLeftWidth', '520');
+ setViewport(500, true);
+ const { result } = renderHook(() => useResizablePanels());
+ expect(result.current.leftWidth).toBe(200);
+ });
+
+ it('FE-HOOK-PANELS-022: a wide layout leaves the stored widths alone', () => {
+ localStorage.setItem('sidebarLeftWidth', '520');
+ setViewport(1100, false);
+ const { result } = renderHook(() => useResizablePanels());
+ expect(result.current.leftWidth).toBe(520);
+ });
});
diff --git a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx
index 7dfe25b34..551a2e5f0 100644
--- a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx
+++ b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx
@@ -519,4 +519,15 @@ describe('NoteRow', () => {
expect(onEdit).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
})
+
+ it('FE-MOB-PLROW-041: a link in the note body opens the link instead of the sheet', () => {
+ const onEdit = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('link', { name: 'the map' }))
+ expect(onEdit).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByText('Buy museum tickets'))
+ expect(onEdit).toHaveBeenCalledTimes(1)
+ })
})