fix(datetime): use scroll listener to detect month changes (#25586)

resolves #25257, resolves #25608, resolves #24980
This commit is contained in:
Liam DeBeasi
2022-07-15 14:58:06 -04:00
committed by GitHub
parent 48ebb9c820
commit b7afcb0f0c
8 changed files with 183 additions and 427 deletions

View File

@ -103,9 +103,8 @@
}
/**
* Safari 14 has an issue where Intersection
* Observer is incorrectly fired when
* unhiding the calendar content.
* Safari 14 has an issue where a scroll event
* is incorrectly fired when unhiding the calendar content.
* To workaround this, we set the opacity
* of the content to 0 and hide it offscreen.
*

View File

@ -67,7 +67,6 @@ export class Datetime implements ComponentInterface {
private calendarBodyRef?: HTMLElement;
private popoverRef?: HTMLIonPopoverElement;
private clearFocusVisible?: () => void;
private overlayIsPresenting = false;
/**
* Whether to highlight the active day with a solid circle (as opposed
@ -85,9 +84,8 @@ export class Datetime implements ComponentInterface {
private parsedYearValues?: number[];
private parsedDayValues?: number[];
private destroyCalendarIO?: () => void;
private destroyCalendarListener?: () => void;
private destroyKeyboardMO?: () => void;
private destroyOverlayListener?: () => void;
private minParts?: any;
private maxParts?: any;
@ -719,20 +717,17 @@ export class Datetime implements ComponentInterface {
};
};
private initializeCalendarIOListeners = () => {
private initializeCalendarListener = () => {
const calendarBodyRef = this.getCalendarBodyEl();
if (!calendarBodyRef) {
return;
}
const mode = getIonMode(this);
/**
* For performance reasons, we only render 3
* months at a time: The current month, the previous
* month, and the next month. We have IntersectionObservers
* on the previous and next month elements to append/prepend
* new months.
* month, and the next month. We have a scroll listener
* on the calendar body to append/prepend new months.
*
* We can do this because Stencil is smart enough to not
* re-create the .calendar-month containers, but rather
@ -748,43 +743,78 @@ export class Datetime implements ComponentInterface {
const startMonth = months[0] as HTMLElement;
const workingMonth = months[1] as HTMLElement;
const endMonth = months[2] as HTMLElement;
const mode = getIonMode(this);
const needsiOSRubberBandFix = mode === 'ios' && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1;
/**
* Before setting up the IntersectionObserver,
* Before setting up the scroll listener,
* scroll the middle month into view.
* scrollIntoView() will scroll entire page
* if element is not in viewport. Use scrollLeft instead.
*/
let endIO: IntersectionObserver | undefined;
let startIO: IntersectionObserver | undefined;
writeTask(() => {
calendarBodyRef.scrollLeft = startMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
const ioCallback = (callbackType: 'start' | 'end', entries: IntersectionObserverEntry[]) => {
const refIO = callbackType === 'start' ? startIO : endIO;
const refMonth = callbackType === 'start' ? startMonth : endMonth;
const refMonthFn = callbackType === 'start' ? getPreviousMonth : getNextMonth;
const getChangedMonth = (parts: DatetimeParts): DatetimeParts | undefined => {
const box = calendarBodyRef.getBoundingClientRect();
const root = this.el!.shadowRoot!;
/**
* If the month is not fully in view, do not do anything
* Get the element that is in the center of the calendar body.
* This will be an element inside of the active month.
*/
const ev = entries[0];
if (!ev.isIntersecting) {
const elementAtCenter = root.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
/**
* If there is no element then the
* component may be re-rendering on a slow device.
*/
if (!elementAtCenter) return;
const month = elementAtCenter.closest('.calendar-month');
if (!month) return;
/**
* The edge of the month must be lined up with
* the edge of the calendar body in order for
* the component to update. Otherwise, it
* may be the case that the user has paused their
* swipe or the browser has not finished snapping yet.
* Rather than check if the x values are equal,
* we give it a tolerance of 2px to account for
* sub pixel rendering.
*/
const monthBox = month.getBoundingClientRect();
if (Math.abs(monthBox.x - box.x) > 2) return;
/**
* From here, we can determine if the start
* month or the end month was scrolled into view.
* If no month was changed, then we can return from
* the scroll callback early.
*/
if (month === startMonth) {
return getPreviousMonth(parts);
} else if (month === endMonth) {
return getNextMonth(parts);
} else {
return;
}
};
const updateActiveMonth = () => {
if (needsiOSRubberBandFix) {
calendarBodyRef.style.removeProperty('pointer-events');
appliediOSRubberBandFix = false;
}
/**
* When presenting an inline overlay,
* subsequent presentations will cause
* the IO to fire again (since the overlay
* is now visible and therefore the calendar
* months are intersecting).
* If the month did not change
* then we can return early.
*/
if (this.overlayIsPresenting) {
this.overlayIsPresenting = false;
return;
}
const newDate = getChangedMonth(this.workingParts);
if (!newDate) return;
const { month, year, day } = refMonthFn(this.workingParts);
const { month, day, year } = newDate;
if (
isMonthDisabled(
@ -798,25 +828,6 @@ export class Datetime implements ComponentInterface {
return;
}
/**
* On iOS, we need to set pointer-events: none
* when the user is almost done with the gesture
* so that they cannot quickly swipe while
* the scrollable container is snapping.
* Updating the container while snapping
* causes WebKit to snap incorrectly.
*/
if (mode === 'ios') {
const ratio = ev.intersectionRatio;
// `maxTouchPoints` will be 1 in device preview, but > 1 on device
const shouldDisable = Math.abs(ratio - 0.7) <= 0.1 && navigator.maxTouchPoints > 1;
if (shouldDisable) {
calendarBodyRef.style.setProperty('pointer-events', 'none');
return;
}
}
/**
* Prevent scrolling for other browsers
* to give the DOM time to update and the container
@ -824,16 +835,6 @@ export class Datetime implements ComponentInterface {
*/
calendarBodyRef.style.setProperty('overflow', 'hidden');
/**
* Remove the IO temporarily
* otherwise you can sometimes get duplicate
* events when rubber banding.
*/
if (refIO === undefined) {
return;
}
refIO.disconnect();
/**
* Use a writeTask here to ensure
* that the state is updated and the
@ -844,85 +845,57 @@ export class Datetime implements ComponentInterface {
* if we did not do this.
*/
writeTask(() => {
// Disconnect all active intersection observers
// to avoid a re-render causing a duplicate event.
if (this.destroyCalendarIO) {
this.destroyCalendarIO();
}
raf(() => {
this.setWorkingParts({
...this.workingParts,
month,
day: day!,
year,
});
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
calendarBodyRef.style.removeProperty('overflow');
calendarBodyRef.style.removeProperty('pointer-events');
endIO?.observe(endMonth);
startIO?.observe(startMonth);
this.setWorkingParts({
...this.workingParts,
month,
day: day!,
year,
});
/**
* Now that state has been updated
* and the correct month is in view,
* we can resume the IO.
*/
if (refIO === undefined) {
return;
}
refIO.observe(refMonth);
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
calendarBodyRef.style.removeProperty('overflow');
});
};
const threshold =
mode === 'ios' && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1 ? [0.7, 1] : 1;
// Intersection observers cannot accurately detect the
// intersection with a threshold of 1, when the observed
// element width is a sub-pixel value (i.e. 334.05px).
// Setting a root margin to 1px solves the issue.
const rootMargin = '1px';
/**
* When the container finishes scrolling we
* need to update the DOM with the selected month.
*/
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* Listen on the first month to
* prepend a new month and on the last
* month to append a new month.
* The 0.7 threshold is required on ios
* so that we can remove pointer-events
* when adding new months.
* Adding to a scroll snapping container
* while the container is snapping does not
* completely work as expected in WebKit.
* Adding pointer-events: none allows us to
* avoid these issues.
*
* This should be fine on Chromium, but
* when you set pointer-events: none
* it applies to active gestures which is not
* something WebKit does.
* We do not want to attempt to set pointer-events
* multiple times within a single swipe gesture as
* that adds unnecessary work to the main thread.
*/
let appliediOSRubberBandFix = false;
const scrollCallback = () => {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
endIO = new IntersectionObserver((ev) => ioCallback('end', ev), {
threshold,
root: calendarBodyRef,
rootMargin,
});
endIO.observe(endMonth);
/**
* On iOS it is possible to quickly rubber band
* the scroll area before the scroll timeout has fired.
* This results in users reaching the end of the scrollable
* container before the DOM has updated.
* By setting `pointer-events: none` we can ensure that
* subsequent swipes do not happen while the container
* is snapping.
*/
if (!appliediOSRubberBandFix && needsiOSRubberBandFix) {
calendarBodyRef.style.setProperty('pointer-events', 'none');
appliediOSRubberBandFix = true;
}
startIO = new IntersectionObserver((ev) => ioCallback('start', ev), {
threshold,
root: calendarBodyRef,
rootMargin,
});
startIO.observe(startMonth);
// Wait ~3 frames
scrollTimeout = setTimeout(updateActiveMonth, 50);
};
this.destroyCalendarIO = () => {
endIO?.disconnect();
startIO?.disconnect();
calendarBodyRef.addEventListener('scroll', scrollCallback);
this.destroyCalendarListener = () => {
calendarBodyRef.removeEventListener('scroll', scrollCallback);
};
});
};
@ -944,10 +917,10 @@ export class Datetime implements ComponentInterface {
* if the datetime has been hidden/presented by a modal or popover.
*/
private destroyInteractionListeners = () => {
const { destroyCalendarIO, destroyKeyboardMO } = this;
const { destroyCalendarListener, destroyKeyboardMO } = this;
if (destroyCalendarIO !== undefined) {
destroyCalendarIO();
if (destroyCalendarListener !== undefined) {
destroyCalendarListener();
}
if (destroyKeyboardMO !== undefined) {
@ -956,9 +929,8 @@ export class Datetime implements ComponentInterface {
};
private initializeListeners() {
this.initializeCalendarIOListeners();
this.initializeCalendarListener();
this.initializeKeyboardListeners();
this.initializeOverlayListener();
}
componentDidLoad() {
@ -1053,37 +1025,10 @@ export class Datetime implements ComponentInterface {
this.prevPresentation = presentation;
this.destroyInteractionListeners();
if (this.destroyOverlayListener !== undefined) {
this.destroyOverlayListener();
}
this.initializeListeners();
}
/**
* When doing subsequent presentations of an inline
* overlay, the IO callback will fire again causing
* the calendar to go back one month. We need to listen
* for the presentation of the overlay so we can properly
* cancel that IO callback.
*/
private initializeOverlayListener = () => {
const overlay = this.el.closest('ion-popover, ion-modal');
if (overlay === null) {
return;
}
const overlayListener = () => {
this.overlayIsPresenting = true;
};
overlay.addEventListener('willPresent', overlayListener);
this.destroyOverlayListener = () => {
overlay.removeEventListener('willPresent', overlayListener);
};
};
private processValue = (value?: string | null) => {
this.highlightActiveParts = !!value;
const valueToProcess = parseDate(value || getToday());
@ -1274,11 +1219,12 @@ export class Datetime implements ComponentInterface {
items={months}
value={workingParts.month}
onIonChange={(ev: CustomEvent) => {
// TODO(FW-1823) Remove this when iOS 14 support is dropped.
// Due to a Safari 14 issue we need to destroy
// the intersection observer before we update state
// the scroll listener before we update state
// and trigger a re-render.
if (this.destroyCalendarIO) {
this.destroyCalendarIO();
if (this.destroyCalendarListener) {
this.destroyCalendarListener();
}
this.setWorkingParts({
@ -1293,9 +1239,9 @@ export class Datetime implements ComponentInterface {
});
}
// We can re-attach the intersection observer after
// We can re-attach the scroll listener after
// the working parts have been updated.
this.initializeCalendarIOListeners();
this.initializeCalendarListener();
ev.stopPropagation();
}}
@ -1308,11 +1254,12 @@ export class Datetime implements ComponentInterface {
items={years}
value={workingParts.year}
onIonChange={(ev: CustomEvent) => {
// TODO(FW-1823) Remove this when iOS 14 support is dropped.
// Due to a Safari 14 issue we need to destroy
// the intersection observer before we update state
// the scroll listener before we update state
// and trigger a re-render.
if (this.destroyCalendarIO) {
this.destroyCalendarIO();
if (this.destroyCalendarListener) {
this.destroyCalendarListener();
}
this.setWorkingParts({
@ -1327,9 +1274,9 @@ export class Datetime implements ComponentInterface {
});
}
// We can re-attach the intersection observer after
// We can re-attach the scroll listener after
// the working parts have been updated.
this.initializeCalendarIOListeners();
this.initializeCalendarListener();
ev.stopPropagation();
}}

View File

@ -183,3 +183,69 @@ test.describe('datetime: footer', () => {
);
});
});
test.describe('datetime: swiping', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.metadata.rtl === true, 'This does not test LTR vs RTL layouts.');
test.skip(testInfo.project.metadata.mode === 'ios', 'This does not have mode-specific logic.');
});
test('should move to prev month by swiping', async ({ page }) => {
await page.setContent(`
<ion-datetime value="2022-05-03"></ion-datetime>
`);
await page.waitForSelector('.datetime-ready');
const calendarBody = page.locator('ion-datetime .calendar-body');
const calendarHeader = page.locator('ion-datetime .calendar-month-year');
await expect(calendarHeader).toHaveText(/May 2022/);
await calendarBody.evaluate((el: HTMLElement) => (el.scrollLeft = 0));
await page.waitForChanges();
await expect(calendarHeader).toHaveText(/April 2022/);
});
test('should move to next month by swiping', async ({ page }) => {
await page.setContent(`
<ion-datetime value="2022-05-03"></ion-datetime>
`);
await page.waitForSelector('.datetime-ready');
const calendarBody = page.locator('ion-datetime .calendar-body');
const calendarHeader = page.locator('ion-datetime .calendar-month-year');
await expect(calendarHeader).toHaveText(/May 2022/);
await calendarBody.evaluate((el: HTMLElement) => (el.scrollLeft = el.scrollWidth));
await page.waitForChanges();
await expect(calendarHeader).toHaveText(/June 2022/);
});
test('should not re-render if swipe is in progress', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Wheel is not available in WebKit');
await page.setContent(`
<ion-datetime value="2022-05-03"></ion-datetime>
`);
await page.waitForSelector('.datetime-ready');
const calendarBody = page.locator('ion-datetime .calendar-body');
const calendarHeader = page.locator('ion-datetime .calendar-month-year');
await expect(calendarHeader).toHaveText(/May 2022/);
const box = await calendarBody.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(-50, 0);
await page.waitForChanges();
await expect(calendarHeader).toHaveText(/May 2022/);
}
});
});

View File

@ -1,45 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('datetime: sub-pixel width', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/datetime/test/sub-pixel-width');
});
test('should update the month when next button is clicked', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
const openModalBtn = page.locator('#open-modal');
await openModalBtn.click();
await ionModalDidPresent.next();
await page.waitForSelector('.datetime-ready');
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(1).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('March 2022');
});
test('should update the month when prev button is clicked', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
const openModalBtn = page.locator('#open-modal');
await openModalBtn.click();
await ionModalDidPresent.next();
await page.waitForSelector('.datetime-ready');
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(0).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('January 2022');
});
});

View File

@ -1,53 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Datetime - Sub Pixel Width</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
ion-datetime {
width: 334.05px;
height: 500px;
}
#background {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Datetime - Sub Pixel Width</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h2>Modal</h2>
<ion-button id="open-modal">Present Modal</ion-button>
<ion-modal trigger="open-modal" id="modal">
<div id="background">
<ion-datetime id="picker" value="2022-02-01"></ion-datetime>
</div>
</ion-modal>
</ion-content>
</ion-app>
<script type="module">
import { InitMonthDidChangeEvent } from '../test/utils/month-did-change-event.js';
document.getElementById('open-modal').addEventListener('click', () => {
InitMonthDidChangeEvent();
});
</script>
</body>
</html>

View File

@ -1,55 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.use({
viewport: {
width: 640,
height: 480,
},
deviceScaleFactor: 2,
});
/**
* This test emulates zoom behavior in the browser to make sure
* that key functions of the ion-datetime continue to function even
* if the page is zoomed in or out.
*/
test.describe('datetime: zoom in interactivity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/datetime/test/zoom');
});
test('should update the month when next button is clicked', async ({ page }) => {
const openModalBtn = page.locator('#open-modal');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
await openModalBtn.click();
await ionModalDidPresent.next();
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(1).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('March 2022');
});
test('should update the month when prev button is clicked', async ({ page }) => {
const openModalBtn = page.locator('#open-modal');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
await openModalBtn.click();
await ionModalDidPresent.next();
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(0).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('January 2022');
});
});

View File

@ -1,55 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.use({
viewport: {
width: 640,
height: 480,
},
deviceScaleFactor: 0.75,
});
/**
* This test emulates zoom behavior in the browser to make sure
* that key functions of the ion-datetime continue to function even
* if the page is zoomed in or out.
*/
test.describe('datetime: zoom out interactivity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/datetime/test/zoom');
});
test('should update the month when next button is clicked', async ({ page }) => {
const openModalBtn = page.locator('#open-modal');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
await openModalBtn.click();
await ionModalDidPresent.next();
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(1).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('March 2022');
});
test('should update the month when prev button is clicked', async ({ page }) => {
const openModalBtn = page.locator('#open-modal');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const datetimeMonthDidChange = await page.spyOnEvent('datetimeMonthDidChange');
await openModalBtn.click();
await ionModalDidPresent.next();
const buttons = page.locator('ion-datetime .calendar-next-prev ion-button');
await buttons.nth(0).click();
await datetimeMonthDidChange.next();
const monthYear = page.locator('ion-datetime .calendar-month-year');
await expect(monthYear).toHaveText('January 2022');
});
});

View File

@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Datetime - Zoom</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
#background {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Datetime - Zoom</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h2>Modal</h2>
<ion-button id="open-modal">Present Modal</ion-button>
<ion-modal trigger="open-modal" id="modal">
<div id="background">
<ion-datetime id="picker" value="2022-02-01"></ion-datetime>
</div>
</ion-modal>
</ion-content>
</ion-app>
<script type="module">
import { InitMonthDidChangeEvent } from '../test/utils/month-did-change-event.js';
document.getElementById('open-modal').addEventListener('click', () => {
InitMonthDidChangeEvent();
});
</script>
</body>
</html>