Compare commits

...

4 Commits

7 changed files with 115 additions and 144 deletions

View File

@@ -21,6 +21,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c
); );
const datetimeButton = page.locator('ion-datetime-button'); const datetimeButton = page.locator('ion-datetime-button');
await page.locator('.datetime-ready').waitFor();
await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale`)); await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale`));
}); });
@@ -40,6 +41,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c
); );
const datetimeButton = page.locator('ion-datetime-button'); const datetimeButton = page.locator('ion-datetime-button');
await page.locator('.datetime-ready').waitFor();
await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale-wrap`)); await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale-wrap`));
}); });

View File

@@ -24,6 +24,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
await dateButton.click(); await dateButton.click();
await ionModalDidPresent.next(); await ionModalDidPresent.next();
// Wait for datetime to be ready before taking screenshot
await page.locator('ion-datetime.datetime-ready').waitFor();
await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-modal`)); await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-modal`));
}); });
@@ -44,6 +47,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
await dateButton.click(); await dateButton.click();
await ionPopoverDidPresent.next(); await ionPopoverDidPresent.next();
// Wait for datetime to be ready before taking screenshot
await page.locator('ion-datetime.datetime-ready').waitFor();
await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-popover`)); await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-popover`));
}); });
}); });

View File

@@ -108,8 +108,8 @@ export class Datetime implements ComponentInterface {
private inputId = `ion-dt-${datetimeIds++}`; private inputId = `ion-dt-${datetimeIds++}`;
private calendarBodyRef?: HTMLElement; private calendarBodyRef?: HTMLElement;
private popoverRef?: HTMLIonPopoverElement; private popoverRef?: HTMLIonPopoverElement;
private intersectionTrackerRef?: HTMLElement;
private clearFocusVisible?: () => void; private clearFocusVisible?: () => void;
private resizeObserver?: ResizeObserver;
private parsedMinuteValues?: number[]; private parsedMinuteValues?: number[];
private parsedHourValues?: number[]; private parsedHourValues?: number[];
private parsedMonthValues?: number[]; private parsedMonthValues?: number[];
@@ -118,6 +118,7 @@ export class Datetime implements ComponentInterface {
private destroyCalendarListener?: () => void; private destroyCalendarListener?: () => void;
private destroyKeyboardMO?: () => void; private destroyKeyboardMO?: () => void;
private destroyOverlayListeners?: () => void;
// TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling)
private minParts?: any; private minParts?: any;
@@ -1077,6 +1078,14 @@ export class Datetime implements ComponentInterface {
this.clearFocusVisible(); this.clearFocusVisible();
this.clearFocusVisible = undefined; this.clearFocusVisible = undefined;
} }
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.destroyOverlayListeners) {
this.destroyOverlayListeners();
this.destroyOverlayListeners = undefined;
}
} }
/** /**
@@ -1102,113 +1111,100 @@ export class Datetime implements ComponentInterface {
} }
/** /**
* TODO(FW-6931): Remove this fallback upon solving the root cause * Sets up visibility detection for the datetime component.
* Fallback to ensure the datetime becomes ready even if
* IntersectionObserver never reports it as intersecting.
* *
* This is primarily used in environments where the observer * Uses multiple strategies to reliably detect when the datetime becomes
* might not fire as expected, such as when running under * visible, which is necessary for proper initialization of scrollable areas:
* synthetic tests that stub IntersectionObserver. * 1. ResizeObserver - detects dimension changes
* 2. Overlay event listeners - for datetime inside modals/popovers
* 3. Polling fallback - for browsers where observers are unreliable (WebKit)
*/ */
private ensureReadyIfVisible = () => { private initializeVisibilityObserver() {
if (this.el.classList.contains('datetime-ready')) { const { el } = this;
return;
const markReady = () => {
if (el.classList.contains('datetime-ready')) {
return;
}
this.initializeListeners();
writeTask(() => {
el.classList.add('datetime-ready');
});
};
const markHidden = () => {
this.destroyInteractionListeners();
this.showMonthAndYear = false;
writeTask(() => {
el.classList.remove('datetime-ready');
});
startVisibilityPolling();
};
/**
* FW-6931: Poll for visibility as a fallback for browsers where
* ResizeObserver doesn't fire reliably (e.g., WebKit).
*/
const startVisibilityPolling = () => {
let pollCount = 0;
const poll = () => {
if (el.classList.contains('datetime-ready') || pollCount++ >= 60) {
return;
}
const { width, height } = el.getBoundingClientRect();
if (width > 0 && height > 0) {
markReady();
} else {
raf(poll);
}
};
raf(poll);
};
/**
* FW-6931: Listen for overlay present/dismiss events when datetime
* is inside a modal or popover.
*/
const parentOverlay = el.closest('ion-modal, ion-popover') as HTMLIonModalElement | HTMLIonPopoverElement | null;
if (parentOverlay) {
const handlePresent = () => markReady();
const handleDismiss = () => markHidden();
parentOverlay.addEventListener('didPresent', handlePresent);
parentOverlay.addEventListener('didDismiss', handleDismiss);
this.destroyOverlayListeners = () => {
parentOverlay.removeEventListener('didPresent', handlePresent);
parentOverlay.removeEventListener('didDismiss', handleDismiss);
};
} }
const rect = this.el.getBoundingClientRect(); if (typeof ResizeObserver !== 'undefined') {
if (rect.width === 0 || rect.height === 0) { this.resizeObserver = new ResizeObserver((entries) => {
return; const { width, height } = entries[0].contentRect;
const isVisible = width > 0 && height > 0;
const isReady = el.classList.contains('datetime-ready');
if (isVisible && !isReady) {
markReady();
} else if (!isVisible && isReady) {
markHidden();
}
});
// Use raf to avoid race condition with modal/popover animations
raf(() => this.resizeObserver?.observe(el));
startVisibilityPolling();
} else {
// Test environment fallback - mark ready immediately
writeTask(() => {
el.classList.add('datetime-ready');
});
} }
}
this.initializeListeners();
writeTask(() => {
this.el.classList.add('datetime-ready');
});
};
componentDidLoad() { componentDidLoad() {
const { el, intersectionTrackerRef } = this; this.initializeVisibilityObserver();
/**
* If a scrollable element is hidden using `display: none`,
* it will not have a scroll height meaning we cannot scroll elements
* into view. As a result, we will need to wait for the datetime to become
* visible if used inside of a modal or a popover otherwise the scrollable
* areas will not have the correct values snapped into place.
*/
const visibleCallback = (entries: IntersectionObserverEntry[]) => {
const ev = entries[0];
if (!ev.isIntersecting) {
return;
}
this.initializeListeners();
/**
* TODO FW-2793: Datetime needs a frame to ensure that it
* can properly scroll contents into view. As a result
* we hide the scrollable content until after that frame
* so users do not see the content quickly shifting. The downside
* is that the content will pop into view a frame after. Maybe there
* is a better way to handle this?
*/
writeTask(() => {
this.el.classList.add('datetime-ready');
});
};
const visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01, root: el });
/**
* Use raf to avoid a race condition between the component loading and
* its display animation starting (such as when shown in a modal). This
* could cause the datetime to start at a visibility of 0, erroneously
* triggering the `hiddenIO` observer below.
*/
raf(() => visibleIO?.observe(intersectionTrackerRef!));
/**
* TODO(FW-6931): Remove this fallback upon solving the root cause
* Fallback: If IntersectionObserver never reports that the
* datetime is visible but the host clearly has layout, ensure
* we still initialize listeners and mark the component as ready.
*
* We schedule this after everything has had a chance to run.
*/
setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);
/**
* We need to clean up listeners when the datetime is hidden
* in a popover/modal so that we can properly scroll containers
* back into view if they are re-presented. When the datetime is hidden
* the scroll areas have scroll widths/heights of 0px, so any snapping
* we did originally has been lost.
*/
const hiddenCallback = (entries: IntersectionObserverEntry[]) => {
const ev = entries[0];
if (ev.isIntersecting) {
return;
}
this.destroyInteractionListeners();
/**
* When datetime is hidden, we need to make sure that
* the month/year picker is closed. Otherwise,
* it will be open when the datetime re-appears
* and the scroll area of the calendar grid will be 0.
* As a result, the wrong month will be shown.
*/
this.showMonthAndYear = false;
writeTask(() => {
this.el.classList.remove('datetime-ready');
});
};
const hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0, root: el });
raf(() => hiddenIO?.observe(intersectionTrackerRef!));
/** /**
* Datetime uses Ionic components that emit * Datetime uses Ionic components that emit
@@ -2693,20 +2689,6 @@ export class Datetime implements ComponentInterface {
}), }),
}} }}
> >
{/*
WebKit has a quirk where IntersectionObserver callbacks are delayed until after
an accelerated animation finishes if the "root" specified in the config is the
browser viewport (the default behavior if "root" is not specified). This means
that when presenting a datetime in a modal on iOS the calendar body appears
blank until the modal animation finishes.
We can work around this by observing .intersection-tracker and using the host
(ion-datetime) as the "root". This allows the IO callback to fire the moment
the datetime is visible. The .intersection-tracker element should not have
dimensions or additional styles, and it should not be positioned absolutely
otherwise the IO callback may fire at unexpected times.
*/}
<div class="intersection-tracker" ref={(el) => (this.intersectionTrackerRef = el)}></div>
{this.renderDatetime(mode)} {this.renderDatetime(mode)}
</Host> </Host>
); );

View File

@@ -395,40 +395,18 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
}); });
/** /**
* Synthetic IntersectionObserver fallback behavior. * Verify that datetime becomes ready via ResizeObserver.
* * This tests that the datetime properly initializes when it has
* This test stubs IntersectionObserver so that the callback * dimensions, using ResizeObserver to detect visibility.
* never reports an intersecting entry. The datetime should
* still become ready via its internal fallback logic.
*/ */
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: IO fallback'), () => { test.describe(title('datetime: visibility detection'), () => {
test('should become ready even if IntersectionObserver never reports visible', async ({ page }, testInfo) => { test('should become ready when rendered with dimensions', async ({ page }, testInfo) => {
testInfo.annotations.push({ testInfo.annotations.push({
type: 'issue', type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30706', description: 'https://github.com/ionic-team/ionic-framework/issues/30706',
}); });
await page.addInitScript(() => {
const OriginalIO = window.IntersectionObserver;
(window as any).IntersectionObserver = function (callback: any, options: any) {
const instance = new OriginalIO(() => {}, options);
const originalObserve = instance.observe.bind(instance);
instance.observe = (target: Element) => {
originalObserve(target);
callback([
{
isIntersecting: false,
target,
} as IntersectionObserverEntry,
]);
};
return instance;
} as any;
});
await page.setContent( await page.setContent(
` `
<ion-datetime value="2022-05-03"></ion-datetime> <ion-datetime value="2022-05-03"></ion-datetime>
@@ -438,8 +416,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
const datetime = page.locator('ion-datetime'); const datetime = page.locator('ion-datetime');
// Give the fallback a short amount of time to run // Wait for the datetime to become ready via ResizeObserver
await page.waitForTimeout(100); await page.locator('.datetime-ready').waitFor();
await expect(datetime).toHaveClass(/datetime-ready/); await expect(datetime).toHaveClass(/datetime-ready/);

View File

@@ -52,6 +52,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('datetime: custom focus'), () => { test.describe(title('datetime: custom focus'), () => {
test('should focus the selected day and then the day after', async ({ page }) => { test('should focus the selected day and then the day after', async ({ page }) => {
await page.goto(`/src/components/datetime/test/custom`, config); await page.goto(`/src/components/datetime/test/custom`, config);
await page.locator('.datetime-ready').last().waitFor();
const datetime = page.locator('#custom-calendar-days'); const datetime = page.locator('#custom-calendar-days');

View File

@@ -7,6 +7,7 @@ configs().forEach(({ title, screenshot, config }) => {
await page.goto('/src/components/datetime/test/first-day-of-week', config); await page.goto('/src/components/datetime/test/first-day-of-week', config);
const datetime = page.locator('ion-datetime'); const datetime = page.locator('ion-datetime');
await page.locator('.datetime-ready').waitFor();
await expect(datetime).toHaveScreenshot(screenshot(`datetime-day-of-week`)); await expect(datetime).toHaveScreenshot(screenshot(`datetime-day-of-week`));
}); });
}); });

View File

@@ -10,6 +10,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
`, `,
config config
); );
await page.locator('.datetime-ready').waitFor();
}); });
test('should render highlights correctly when using an array', async ({ page }) => { test('should render highlights correctly when using an array', async ({ page }) => {