mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
4 Commits
main
...
fix/dateti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8772e8bd9 | ||
|
|
12ef9345d6 | ||
|
|
79d5716baf | ||
|
|
14419288c0 |
@@ -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`));
|
||||
});
|
||||
|
||||
@@ -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`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user