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');
await page.locator('.datetime-ready').waitFor();
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');
await page.locator('.datetime-ready').waitFor();
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 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`));
});
@@ -44,6 +47,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
await dateButton.click();
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`));
});
});

View File

@@ -108,8 +108,8 @@ export class Datetime implements ComponentInterface {
private inputId = `ion-dt-${datetimeIds++}`;
private calendarBodyRef?: HTMLElement;
private popoverRef?: HTMLIonPopoverElement;
private intersectionTrackerRef?: HTMLElement;
private clearFocusVisible?: () => void;
private resizeObserver?: ResizeObserver;
private parsedMinuteValues?: number[];
private parsedHourValues?: number[];
private parsedMonthValues?: number[];
@@ -118,6 +118,7 @@ export class Datetime implements ComponentInterface {
private destroyCalendarListener?: () => void;
private destroyKeyboardMO?: () => void;
private destroyOverlayListeners?: () => void;
// TODO(FW-2832): types (DatetimeParts causes some errors that need untangling)
private minParts?: any;
@@ -1077,6 +1078,14 @@ export class Datetime implements ComponentInterface {
this.clearFocusVisible();
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
* Fallback to ensure the datetime becomes ready even if
* IntersectionObserver never reports it as intersecting.
* Sets up visibility detection for the datetime component.
*
* This is primarily used in environments where the observer
* might not fire as expected, such as when running under
* synthetic tests that stub IntersectionObserver.
* Uses multiple strategies to reliably detect when the datetime becomes
* visible, which is necessary for proper initialization of scrollable areas:
* 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 = () => {
if (this.el.classList.contains('datetime-ready')) {
return;
private initializeVisibilityObserver() {
const { el } = this;
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 (rect.width === 0 || rect.height === 0) {
return;
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver((entries) => {
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() {
const { el, intersectionTrackerRef } = this;
/**
* 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!));
this.initializeVisibilityObserver();
/**
* 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)}
</Host>
);

View File

@@ -395,40 +395,18 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
});
/**
* Synthetic IntersectionObserver fallback behavior.
*
* This test stubs IntersectionObserver so that the callback
* never reports an intersecting entry. The datetime should
* still become ready via its internal fallback logic.
* Verify that datetime becomes ready via ResizeObserver.
* This tests that the datetime properly initializes when it has
* dimensions, using ResizeObserver to detect visibility.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: IO fallback'), () => {
test('should become ready even if IntersectionObserver never reports visible', async ({ page }, testInfo) => {
test.describe(title('datetime: visibility detection'), () => {
test('should become ready when rendered with dimensions', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
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(
`
<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');
// Give the fallback a short amount of time to run
await page.waitForTimeout(100);
// Wait for the datetime to become ready via ResizeObserver
await page.locator('.datetime-ready').waitFor();
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('should focus the selected day and then the day after', async ({ page }) => {
await page.goto(`/src/components/datetime/test/custom`, config);
await page.locator('.datetime-ready').last().waitFor();
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);
const datetime = page.locator('ion-datetime');
await page.locator('.datetime-ready').waitFor();
await expect(datetime).toHaveScreenshot(screenshot(`datetime-day-of-week`));
});
});

View File

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