mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
fix(mobile): swiping days ticks as the swipe is decided, and a quick second swipe reaches the day after
The detent came only once the strip had settled, which on iOS is a good while after the finger lifts, so it felt late. It now ticks as soon as another day becomes the nearer page, and no second time as the snap lands. The strip held the selected day and one on either side. A second swipe made while the first still snapped ran into the end of the strip and sprang back, so paging several days took a pause after each. It now holds two on either side, and the second swipe carries on to the day after.
This commit is contained in:
@@ -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<Parameters<typeof MDayPager>[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', () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user