From 953720c874ed21c05ab14dc782d0b58da3596ea0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 13:15:38 +0200 Subject: [PATCH] fix(client): clamp a flipped date picker to the viewport Anchoring the flipped popup to the trigger's top edge dropped the old vertical clamp: with a short viewport the popup's top edge left the screen and the month header became unreachable. The popup now flips up only when there is room above; otherwise it stays below and is clamped so its top edge never leaves the viewport. --- .../shared/CustomDateTimePicker.test.tsx | 25 ++++++++++++++++++- .../shared/CustomDateTimePicker.tsx | 8 ++++-- 2 files changed, 30 insertions(+), 3 deletions(-) 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)',