mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
4 Commits
ionic-modu
...
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');
|
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`));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user