diff --git a/client/src/mobile/components/MDayPager.test.tsx b/client/src/mobile/components/MDayPager.test.tsx index ee3ae7a46..2b6083e82 100644 --- a/client/src/mobile/components/MDayPager.test.tsx +++ b/client/src/mobile/components/MDayPager.test.tsx @@ -7,7 +7,7 @@ import { pagesFor } from './dayPagerPages'; vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); -const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }] as unknown as Day[]; +const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[]; function setup(selectedDayId: number | null, over: Partial[0]> = {}) { const onSelectDay = vi.fn(); @@ -39,17 +39,19 @@ beforeEach(() => { }); describe('pagesFor', () => { - it('windows the selection with its neighbours', () => { - expect(pagesFor(DAYS, 2).map((p) => [p.dayId, p.active])).toEqual([ + it('windows the selection with two days on either side, room for a second swipe', () => { + expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active])).toEqual([ [1, false], - [2, true], - [3, false], + [2, false], + [3, true], + [4, false], + [5, false], ]); }); it('has no page beyond the first and last day', () => { - expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2]); - expect(pagesFor(DAYS, 4).map((p) => p.dayId)).toEqual([3, 4]); + expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2, 3]); + expect(pagesFor(DAYS, 6).map((p) => p.dayId)).toEqual([4, 5, 6]); }); it('keeps a page for a selection that points at no day, with the first day as the way back', () => { @@ -67,20 +69,41 @@ describe('MDayPager', () => { expect(screen.getByText('page 2 live')).toBeInTheDocument(); const pages = Array.from(strip.children) as HTMLElement[]; - expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true]); + expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true]); expect(pages[0]).toHaveAttribute('aria-hidden', 'true'); expect(pages[1]).not.toHaveAttribute('aria-hidden'); }); - it('selects the neighbour the strip comes to rest on, with a detent and an announcement', () => { + it('selects the day the strip comes to rest on, with an announcement', () => { const { strip, onSelectDay } = setup(2); place(strip, 3); fireEvent(strip, new Event('scrollend')); expect(onSelectDay).toHaveBeenCalledWith(3); + expect(screen.getByRole('status')).toHaveTextContent('day 3 of 6'); + }); + + it('ticks once as the swipe crosses to another day, while the strip still moves, not again as it lands', () => { + const { strip } = setup(2); + + place(strip, 3, true); + fireEvent.scroll(strip); + fireEvent.scroll(strip); expect(tickHaptic).toHaveBeenCalledTimes(1); - expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4'); + + place(strip, 3); + fireEvent(strip, new Event('scrollend')); + expect(tickHaptic).toHaveBeenCalledTimes(1); + }); + + it('a second swipe made during the snap carries on to the day after', () => { + const { strip, onSelectDay } = setup(2); + + place(strip, 4); + fireEvent(strip, new Event('scrollend')); + + expect(onSelectDay).toHaveBeenCalledWith(4); }); it('previews the day a swipe heads for while the strip still moves, and takes it back', () => { diff --git a/client/src/mobile/components/MDayPager.tsx b/client/src/mobile/components/MDayPager.tsx index b8eedea56..303f44e69 100644 --- a/client/src/mobile/components/MDayPager.tsx +++ b/client/src/mobile/components/MDayPager.tsx @@ -4,14 +4,18 @@ import { tickHaptic } from '../../native/haptics'; import { pagesFor, type PagerItem } from './dayPagerPages'; /** - * Pages through the trip's days the way a native pager does: the previous, - * current and next day sit side by side in a horizontal scroll-snap strip, so - * the finger drags real paper, the neighbour slides in with it, and the - * browser's compositor owns the gesture. Momentum, rubber-banding at the first + * Pages through the trip's days the way a native pager does: the current day + * and two on either side sit side by side in a horizontal scroll-snap strip + * (dayPagerPages.ts), so the finger drags real paper, the neighbour slides in + * with it, and a second swipe made while the first still snaps carries on to + * the day after. The browser's compositor owns the gesture. Momentum, rubber-banding at the first * and last day, axis locking against the vertical scroll and the swallowed tap * after a swipe are all the platform's own, in the PWA and the app alike. * - * The day only changes once the strip has come to rest on a neighbour. Then + * The phone ticks as soon as another page becomes the nearer one, the moment + * the swipe is decided, not once the snap has settled, which on iOS comes + * a good while later. The day only changes once the strip has come to rest on + * another page. Then * the selection moves, the window of three shifts by one and the strip is put * back on the centre page before the next paint, so nothing visibly jumps. * Keys follow the day ids, so the page that slid in keeps its state (scroll @@ -110,10 +114,9 @@ export default function MDayPager({ let previewed: number | null = null; // The nearer page while the strip moves: a page past the middle is where the - // swipe will land unless the finger turns back. + // swipe will land unless the finger turns back. Ticks as it changes to + // another day, so the finger feels the detent while it is still moving. const preview = () => { - const notify = latest.current.onPreview; - if (!notify) return; const left = strip.getBoundingClientRect().left; let nearest: HTMLElement | null = null; let distance = Infinity; @@ -130,7 +133,8 @@ export default function MDayPager({ const next = dayId != null && Number.isFinite(dayId) ? dayId : null; if (next === previewed) return; previewed = next; - notify(next); + if (next !== null) tickHaptic(); + latest.current.onPreview?.(next); }; const settle = () => { @@ -148,7 +152,6 @@ export default function MDayPager({ requested.current = dayId; previewed = null; const s = latest.current; - tickHaptic(); s.onSelectDay(dayId); const index = s.days.findIndex((d) => d.id === dayId); if (index >= 0) setAnnouncement(s.describeDay(index, s.days.length)); diff --git a/client/src/mobile/components/dayPagerPages.ts b/client/src/mobile/components/dayPagerPages.ts index a86533723..b20bda675 100644 --- a/client/src/mobile/components/dayPagerPages.ts +++ b/client/src/mobile/components/dayPagerPages.ts @@ -9,6 +9,14 @@ interface Page { active: boolean; } +/** + * Pages kept either side of the selection. Two, not one: a second swipe that + * starts while the first is still snapping has to find a page beyond the + * neighbour, or it runs into the end of the strip and springs back instead of + * reaching the day after. + */ +const REACH = 2; + /** The window of pages around the selection. A selection that points at no * day (the whole-trip view, or a day deleted remotely) still gets its page, * with the first day next to it as a way back. */ @@ -21,11 +29,7 @@ export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[ ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []), ]; } - const pages: Page[] = []; - const prev = days[i - 1]; - const next = days[i + 1]; - if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false }); - pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true }); - if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false }); - return pages; + return days + .slice(Math.max(0, i - REACH), i + REACH + 1) + .map((d) => ({ key: `d${d.id}`, dayId: d.id, active: d.id === days[i].id })); }