Skip to content

Commit 9469718

Browse files
committed
fix(datetime): harden visibility logic
1 parent 92db364 commit 9469718

File tree

4 files changed

+135
-3
lines changed

4 files changed

+135
-3
lines changed

core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,5 +176,26 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
176176
await ionModalDidPresent.next();
177177
await expect(datetime).toBeVisible();
178178
});
179+
test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ page }) => {
180+
const openAndInteract = async () => {
181+
await page.click('#date-button');
182+
await ionModalDidPresent.next();
183+
184+
await page.locator('ion-datetime.datetime-ready').waitFor();
185+
186+
const calendarBody = datetime.locator('.calendar-body');
187+
await expect(calendarBody).toBeVisible();
188+
189+
const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first();
190+
await firstEnabledDay.click();
191+
await page.waitForChanges();
192+
193+
await modal.evaluate((el: HTMLIonModalElement) => el.dismiss());
194+
await ionModalDidDismiss.next();
195+
};
196+
197+
await openAndInteract();
198+
await openAndInteract();
199+
});
179200
});
180201
});

core/src/components/datetime/datetime.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@
4949
width: 100%;
5050
}
5151

52+
/**
53+
* The intersection tracker participates in layout so
54+
* IntersectionObserver has a non-zero rect to observe.
55+
* A negative margin keeps the effective host height
56+
* unchanged so pages embedding ion-datetime (such as
57+
* item input demos) do not grow by 1px.
58+
*/
59+
:host .intersection-tracker {
60+
width: 100%;
61+
height: 1px;
62+
63+
margin-bottom: -1px;
64+
65+
opacity: 0;
66+
pointer-events: none;
67+
}
68+
5269
:host .calendar-body,
5370
:host .datetime-year {
5471
opacity: 0;

core/src/components/datetime/datetime.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,31 @@ export class Datetime implements ComponentInterface {
11011101
this.initializeKeyboardListeners();
11021102
}
11031103

1104+
/**
1105+
* Fallback to ensure the datetime becomes ready even if
1106+
* IntersectionObserver never reports it as intersecting.
1107+
*
1108+
* This is primarily used in environments where the observer
1109+
* might not fire as expected, such as when running under
1110+
* synthetic tests that stub IntersectionObserver.
1111+
*/
1112+
private ensureReadyIfVisible = () => {
1113+
if (this.el.classList.contains('datetime-ready')) {
1114+
return;
1115+
}
1116+
1117+
const rect = this.el.getBoundingClientRect();
1118+
if (rect.width === 0 || rect.height === 0) {
1119+
return;
1120+
}
1121+
1122+
this.initializeListeners();
1123+
1124+
writeTask(() => {
1125+
this.el.classList.add('datetime-ready');
1126+
});
1127+
};
1128+
11041129
componentDidLoad() {
11051130
const { el, intersectionTrackerRef } = this;
11061131

@@ -1141,6 +1166,21 @@ export class Datetime implements ComponentInterface {
11411166
*/
11421167
raf(() => visibleIO?.observe(intersectionTrackerRef!));
11431168

1169+
/**
1170+
* Fallback: If IntersectionObserver never reports that the
1171+
* datetime is visible but the host clearly has layout, ensure
1172+
* we still initialize listeners and mark the component as ready.
1173+
*
1174+
* We schedule this a couple of frames after load so that any
1175+
* initial layout/animations (such as a parent modal presenting)
1176+
* have had a chance to run.
1177+
*/
1178+
raf(() => {
1179+
raf(() => {
1180+
this.ensureReadyIfVisible();
1181+
});
1182+
});
1183+
11441184
/**
11451185
* We need to clean up listeners when the datetime is hidden
11461186
* in a popover/modal so that we can properly scroll containers
@@ -2664,9 +2704,9 @@ export class Datetime implements ComponentInterface {
26642704
26652705
We can work around this by observing .intersection-tracker and using the host
26662706
(ion-datetime) as the "root". This allows the IO callback to fire the moment
2667-
the datetime is visible. The .intersection-tracker element should not have
2668-
dimensions or additional styles, and it should not be positioned absolutely
2669-
otherwise the IO callback may fire at unexpected times.
2707+
the datetime is visible. The .intersection-tracker element uses a minimal,
2708+
invisible block size so it participates in layout, and it should not be
2709+
positioned absolutely otherwise the IO callback may fire at unexpected times.
26702710
*/}
26712711
<div class="intersection-tracker" ref={(el) => (this.intersectionTrackerRef = el)}></div>
26722712
{this.renderDatetime(mode)}

core/src/components/datetime/test/basic/datetime.e2e.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,60 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
394394
});
395395
});
396396

397+
/**
398+
* Synthetic IntersectionObserver fallback behavior.
399+
*
400+
* This test stubs IntersectionObserver so that the callback
401+
* never reports an intersecting entry. The datetime should
402+
* still become ready via its internal fallback logic.
403+
*/
404+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
405+
test.describe(title('datetime: IO fallback'), () => {
406+
test('should become ready even if IntersectionObserver never reports visible', async ({ page }) => {
407+
await page.addInitScript(() => {
408+
const OriginalIO = window.IntersectionObserver;
409+
(window as any).IntersectionObserver = function (callback: any, options: any) {
410+
const instance = new OriginalIO(() => {}, options);
411+
const originalObserve = instance.observe.bind(instance);
412+
413+
instance.observe = (target: Element) => {
414+
originalObserve(target);
415+
callback([
416+
{
417+
isIntersecting: false,
418+
target,
419+
} as IntersectionObserverEntry,
420+
]);
421+
};
422+
423+
return instance;
424+
} as any;
425+
});
426+
427+
await page.setContent(
428+
`
429+
<ion-datetime value="2022-05-03"></ion-datetime>
430+
`,
431+
config
432+
);
433+
434+
const datetime = page.locator('ion-datetime');
435+
436+
// Give the fallback a short amount of time to run
437+
await page.waitForTimeout(100);
438+
439+
await expect(datetime).toHaveClass(/datetime-ready/);
440+
441+
const calendarBody = datetime.locator('.calendar-body');
442+
await expect(calendarBody).toBeVisible();
443+
444+
const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first();
445+
await firstEnabledDay.click();
446+
await page.waitForChanges();
447+
});
448+
});
449+
});
450+
397451
/**
398452
* We are setting RTL on the component
399453
* instead, so we don't need to test

0 commit comments

Comments
 (0)