fix(journey): two quick swipes in the photo viewer turn pages instead of zooming

A double tap counted every lift of a finger, the end of a swipe included, so
two quick swipes zoomed in. Only a touch that stays in place is a tap now.
The client tests follow the fields and arguments the qol sweep added.
This commit is contained in:
Maurice
2026-09-30 23:30:51 +02:00
parent f5103d04e0
commit d6435572b7
10 changed files with 36 additions and 14 deletions
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-020
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -213,6 +213,17 @@ describe('PhotoLightbox', () => {
expect(screen.getByRole('img').style.transform).toBe('translate(0px, 0px) scale(1)');
});
it('FE-COMP-LIGHTBOX-021: two quick taps zoom in, two quick swipes turn two pages', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={0} onClose={vi.fn()} />);
const surface = screen.getByRole('img').closest('div[style*="position: fixed"]') as HTMLElement;
for (let i = 0; i < 2; i++) {
fireEvent.touchStart(surface, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchEnd(surface, { touches: [], changedTouches: [{ clientX: 202, clientY: 201 }] });
}
expect(screen.getByRole('button', { name: 'Zoom out' })).not.toBeDisabled();
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-019: a swipe on an enlarged photo pans instead of turning the page', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
+10 -1
View File
@@ -3,6 +3,8 @@ import { MAX_ZOOM, NO_ZOOM, panBy, toggleZoom, zoomAt, type ZoomState } from './
const STEP = 1.5
const DOUBLE_TAP_MS = 300
/** How far a finger may move and still have tapped rather than swiped. */
const TAP_SLOP_PX = 10
/**
* Zoom and pan for one photo of the lightbox (#1484): the wheel and a double click
@@ -17,6 +19,8 @@ export function usePhotoZoom(resetKey: unknown) {
const pinch = useRef<{ dist: number; scale: number } | null>(null)
const drag = useRef<{ x: number; y: number } | null>(null)
const lastTap = useRef(0)
// Where a one finger touch began, so a swipe is never counted as a tap.
const tapStart = useRef<{ x: number; y: number } | null>(null)
useEffect(() => { setZoom(NO_ZOOM) }, [resetKey])
@@ -74,6 +78,7 @@ export function usePhotoZoom(resetKey: unknown) {
* gesture was a zoom or a pan, so a pinch never also turns the page.
*/
const touchStart = useCallback((e: React.TouchEvent): boolean => {
tapStart.current = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null
if (e.touches.length === 2) {
const [a, b] = [e.touches[0], e.touches[1]]
pinch.current = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: zoomRef.current.scale }
@@ -114,7 +119,11 @@ export function usePhotoZoom(resetKey: unknown) {
if (e.touches.length < 2) pinch.current = null
if (e.touches.length === 0) drag.current = null
// A double tap zooms in on the tapped spot, or back out.
if (!wasGesture && e.changedTouches.length === 1) {
const t = e.changedTouches[0]
const start = tapStart.current
tapStart.current = null
const isTap = !!start && !!t && Math.hypot(t.clientX - start.x, t.clientY - start.y) < TAP_SLOP_PX
if (!wasGesture && e.changedTouches.length === 1 && isTap) {
const now = Date.now()
if (now - lastTap.current < DOUBLE_TAP_MS) {
lastTap.current = 0
@@ -806,10 +806,10 @@ describe('TodoListPanel — detail pane', () => {
]} />);
await user.click(screen.getByText('Plan route'));
expect(await screen.findByDisplayValue('coast')).toBeInTheDocument();
expect((await screen.findAllByText('coast')).length).toBeGreaterThan(0);
await user.click(screen.getByText('Book ferry'));
await waitFor(() => expect(screen.queryByDisplayValue('coast')).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByDisplayValue('Book ferry')).toBeInTheDocument());
expect(screen.getByDisplayValue('Book ferry')).toBeInTheDocument();
});
});
@@ -171,7 +171,7 @@ describe('VacayCalendar overlays', () => {
fireEvent.click(screen.getByRole('button', { name: 'Company Holiday' }))
fireEvent.click(screen.getByText('company-5'))
await waitFor(() => expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-12-24'))
await waitFor(() => expect(toggleCompanyHoliday.mock.calls[0]?.[0]).toBe('2026-12-24'))
})
it('FE-COMP-VCYCAL-007b: company mode left over from an enabled plan stops logging', async () => {
@@ -125,7 +125,7 @@ describe('MAtlasCountryPopup', () => {
fireEvent.click(screen.getByText('atlas.addToBucket'))
await waitFor(() => expect(setConfirmAction).toHaveBeenCalledWith(null))
expect(body).toEqual({ name: 'France', country_code: 'FR', target_date: '2027-05' })
expect(body).toMatchObject({ name: 'France', country_code: 'FR', target_date: '2027-05' })
expect(setBucketList).toHaveBeenCalled()
})
})
+4 -3
View File
@@ -352,7 +352,7 @@ describe('AtlasPage wiring', () => {
fireEvent.click(screen.getByText('atlas.addToBucket'));
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenLastCalledWith(null));
expect(body).toEqual({ name: 'Germany', country_code: 'DE', target_date: '2027-05' });
expect(body).toMatchObject({ name: 'Germany', country_code: 'DE', target_date: '2027-05' });
expect(atlas.bucketList as unknown[]).toHaveLength(1);
expect(atlas.setBucketMonth).toHaveBeenLastCalledWith(0);
expect(atlas.setBucketYear).toHaveBeenLastCalledWith(0);
@@ -376,7 +376,7 @@ describe('AtlasPage wiring', () => {
fireEvent.click(screen.getByText('atlas.addToBucket'));
await waitFor(() => expect(body).toEqual({ name: 'Bretagne', country_code: 'FR', target_date: null }));
await waitFor(() => expect(body).toMatchObject({ name: 'Bretagne', country_code: 'FR', target_date: null }));
});
it('FE-PAGE-ATLASW-018b: a country already wishlisted for that date is not posted again (#1898)', async () => {
@@ -460,7 +460,8 @@ describe('AtlasPage wiring', () => {
render(<AtlasPage />);
expect(screen.getByText('France')).toBeInTheDocument();
expect(screen.getByText('3 atlas.places · 4 atlas.tripPlural')).toBeInTheDocument();
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '3 atlas.places').length).toBeGreaterThan(0);
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '4 atlas.tripPlural').length).toBeGreaterThan(0);
// Only the first three trips are shown.
expect(screen.queryByText('Brest')).not.toBeInTheDocument();
@@ -1074,7 +1074,7 @@ describe('useTripPlanner — add place entry points', () => {
act(() => { result.current.openAddPlaceFromPoi(trailhead) })
// Only the fields the form has: the details, icon and colour stay on the map.
expect(result.current.prefillCoords).toEqual({
expect(result.current.prefillCoords).toMatchObject({
lat: 47.1, lng: 11.2, name: 'Trailhead', address: 'Hut 1', website: 'https://trails.example/th-1', phone: '+43 1',
osm_id: 'plugin:trail-finder:th-1', stop_type: null, duration_minutes: undefined,
})
@@ -133,7 +133,8 @@ describe('MAtlas', () => {
render(<MAtlas />);
expect(await screen.findByText('Japan')).toBeInTheDocument();
expect(screen.getByText('2 atlas.places · 2 atlas.trips')).toBeInTheDocument();
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.places').length).toBeGreaterThan(0);
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.trips').length).toBeGreaterThan(0);
expect(screen.getByText('Kansai')).toBeInTheDocument();
});
@@ -230,7 +230,7 @@ describe('MAtlasCountryPopup', () => {
fireEvent.click(screen.getByText('atlas.addToBucket'));
await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null));
expect(body).toEqual({ name: 'Germany', country_code: 'DE', target_date: '2027-05' });
expect(body).toMatchObject({ name: 'Germany', country_code: 'DE', target_date: '2027-05' });
expect(atlas.bucketList).toEqual([
{ id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' },
]);
@@ -137,7 +137,7 @@ describe('ImpFileStep', () => {
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(importGpx).toHaveBeenCalledWith(1, file, { waypoints: true, routes: false, tracks: true })
expect(importGpx).toHaveBeenCalledWith(1, file, { waypoints: true, routes: false, tracks: true, enrich: false })
expect(planner.tripActions.loadTrip).toHaveBeenCalledWith(1)
expect(planner.toast.success).toHaveBeenCalledWith('places.gpxImported:3')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importGpx', expect.any(Function))
@@ -183,7 +183,7 @@ describe('ImpFileStep', () => {
fireEvent.click(submit())
await screen.findByText('places.kmlKmzSummaryValues:5,4,1')
expect(importMapFile).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(importMapFile).toHaveBeenCalledWith(1, file, { points: true, paths: false, enrich: false })
expect(screen.getByText('1 placemark had no coordinates')).toBeInTheDocument()
expect(planner.toast.success).toHaveBeenCalledWith('places.kmlKmzImported:4')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function))