Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
});
Expand All @@ -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`));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
});

Expand All @@ -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`));
});
});
Expand Down
182 changes: 82 additions & 100 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}

/**
Expand All @@ -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;
}

const rect = this.el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return;
}

this.initializeListeners();

writeTask(() => {
this.el.classList.add('datetime-ready');
});
};
private initializeVisibilityObserver() {
const { el } = this;

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) {
const markReady = () => {
if (el.classList.contains('datetime-ready')) {
return;
}

this.initializeListeners();
writeTask(() => {
el.classList.add('datetime-ready');
});
};

/**
* 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?
*/
const markHidden = () => {
this.destroyInteractionListeners();
this.showMonthAndYear = false;
writeTask(() => {
this.el.classList.add('datetime-ready');
el.classList.remove('datetime-ready');
});
startVisibilityPolling();
};
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.
* FW-6931: Poll for visibility as a fallback for browsers where
* ResizeObserver doesn't fire reliably (e.g., WebKit).
*/
raf(() => visibleIO?.observe(intersectionTrackerRef!));
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);
};

/**
* 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.
* FW-6931: Listen for overlay present/dismiss events when datetime
* is inside a modal or popover.
*/
setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);
const parentOverlay = el.closest('ion-modal, ion-popover') as HTMLIonModalElement | HTMLIonPopoverElement | null;
if (parentOverlay) {
const handlePresent = () => markReady();
const handleDismiss = () => markHidden();

/**
* 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;
}
parentOverlay.addEventListener('didPresent', handlePresent);
parentOverlay.addEventListener('didDismiss', handleDismiss);

this.destroyInteractionListeners();
this.destroyOverlayListeners = () => {
parentOverlay.removeEventListener('didPresent', handlePresent);
parentOverlay.removeEventListener('didDismiss', handleDismiss);
};
}

/**
* 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;
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(() => {
this.el.classList.remove('datetime-ready');
el.classList.add('datetime-ready');
});
};
const hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0, root: el });
raf(() => hiddenIO?.observe(intersectionTrackerRef!));
}
}

componentDidLoad() {
this.initializeVisibilityObserver();

/**
* Datetime uses Ionic components that emit
Expand Down Expand Up @@ -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>
);
Expand Down
36 changes: 7 additions & 29 deletions core/src/components/datetime/test/basic/datetime.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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/);

Expand Down
1 change: 1 addition & 0 deletions core/src/components/datetime/test/custom/datetime.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading