diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx
index d3f741d4e..4e152b2bc 100644
--- a/client/src/components/shared/CustomDateTimePicker.test.tsx
+++ b/client/src/components/shared/CustomDateTimePicker.test.tsx
@@ -608,7 +608,7 @@ describe('CustomDatePicker branches', () => {
}
});
- it('FE-W5DP-024: a flipped popup stays flush against its trigger instead of a fixed guessed offset (#2334)', () => {
+ it('FE-W5DP-024: a flipped popup stays flush against its trigger instead of a fixed guessed offset', () => {
// A settle-up-style form: the trigger sits low enough that the popup can't
// fit below it, but there is plenty of room above — a shorter gap than the
// worst-case guess would previously leave the popup floating over unrelated
@@ -633,6 +633,29 @@ describe('CustomDatePicker branches', () => {
}
});
+ it('FE-W5DP-025: a popup with no room on either side keeps its top edge inside the viewport', () => {
+ // A phone in landscape: 375px tall with the trigger mid-screen. The 360px
+ // popup fits neither below nor above, and flipping regardless would put
+ // its top edge 200px off-screen with no way to scroll to the month header.
+ const originalRect = Element.prototype.getBoundingClientRect;
+ const originalHeight = window.innerHeight;
+ Element.prototype.getBoundingClientRect = function () {
+ return { left: 20, top: 150, right: 220, bottom: 190, width: 200, height: 40, x: 20, y: 150, toJSON: () => ({}) } as DOMRect;
+ };
+ Object.defineProperty(window, 'innerHeight', { value: 375, configurable: true });
+
+ try {
+ render();
+ openCalendar();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog.style.bottom).toBe('');
+ expect(dialog.style.top).toBe('8px');
+ } finally {
+ Element.prototype.getBoundingClientRect = originalRect;
+ Object.defineProperty(window, 'innerHeight', { value: originalHeight, configurable: true });
+ }
+ });
+
it('FE-W5DP-018: the header arrows and the label highlight on hover', () => {
render();
openCalendar();
diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx
index 2d189aaf7..3bdb3441e 100644
--- a/client/src/components/shared/CustomDateTimePicker.tsx
+++ b/client/src/components/shared/CustomDateTimePicker.tsx
@@ -398,7 +398,8 @@ export function CustomDatePicker({
let left = r.left;
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad);
if (vw < 360) left = Math.max(pad, (vw - w) / 2);
- if (r.bottom + 4 + h <= vh - pad) return { top: r.bottom + 4, left };
+ const below = r.bottom + 4;
+ if (below + h <= vh - pad) return { top: below, left };
// Flipped: anchor from the trigger's own top edge via `bottom`
// instead of subtracting a guessed height from `top`. A guessed
// `top` leaves a gap sized by however wrong the guess was — in a
@@ -406,7 +407,10 @@ export function CustomDatePicker({
// land the popup over unrelated fields instead of the trigger it
// belongs to. Anchoring the opposite edge means the popup always
// sits flush against the trigger no matter its real height.
- return { bottom: Math.max(pad, vh - r.top + 4), left };
+ if (r.top - 4 - h >= pad) return { bottom: vh - r.top + 4, left };
+ // No room on either side (a phone in landscape): keep the top edge
+ // inside the viewport and let the popup cover the trigger instead.
+ return { top: Math.max(pad, Math.min(below, vh - h - pad)), left };
})(),
zIndex: 99999,
background: 'var(--bg-card)',