From bef11c522269e7128ef635ac1e1c054b76ae43d2 Mon Sep 17 00:00:00 2001 From: RemEx <74838147+Rem7474@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:40:52 +0000 Subject: [PATCH] fix(client): keep a flipped date-picker popup anchored to its trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomDatePicker flipped above a trigger by subtracting a fixed worst-case height (360) from its top, regardless of the popup's real rendered height. When that guess overshot — a short calendar view, or a form with little room, like the settle-up modal — the gap left the popup floating away from its trigger and over unrelated fields instead of sitting right above it (#2334, found while testing the settle-up date field). Anchor the flipped case from the trigger's own top edge via `bottom` instead, the same technique useAnchoredPosition already uses for other popovers, so the popup is always flush against its trigger no matter its actual height. --- .../shared/CustomDateTimePicker.test.tsx | 32 +++++++++++++++++-- .../shared/CustomDateTimePicker.tsx | 18 ++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx index 986e2419e..d3f741d4e 100644 --- a/client/src/components/shared/CustomDateTimePicker.test.tsx +++ b/client/src/components/shared/CustomDateTimePicker.test.tsx @@ -577,9 +577,12 @@ describe('CustomDatePicker branches', () => { render(); openCalendar(); const dialog = screen.getByRole('dialog'); - // vw < 360 centres the 268px popup; the popup flips above the trigger + // vw < 360 centres the 268px popup; the popup flips above the trigger, + // anchored from the trigger's own top edge via `bottom` (not a `top` + // computed by subtracting a guessed popup height — see FE-W5DP-024). expect(dialog.style.left).toBe('26px'); - expect(dialog.style.top).toBe('336px'); + expect(dialog.style.top).toBe(''); + expect(dialog.style.bottom).toBe('72px'); } finally { Element.prototype.getBoundingClientRect = originalRect; Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true }); @@ -605,6 +608,31 @@ describe('CustomDatePicker branches', () => { } }); + it('FE-W5DP-024: a flipped popup stays flush against its trigger instead of a fixed guessed offset (#2334)', () => { + // 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 + // fields higher up the form instead of right above its own trigger. + const originalRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + return { left: 440, top: 529, right: 803, bottom: 566, width: 363, height: 37, x: 440, y: 529, toJSON: () => ({}) } as DOMRect; + }; + + try { + render(); + openCalendar(); + const dialog = screen.getByRole('dialog'); + // jsdom's default innerHeight (768) leaves 768-566=202px below the + // trigger — short of the flip threshold — so it flips, anchored to the + // trigger's own top edge (768-529+4=243), not a `top` derived from + // subtracting a guessed popup height. + expect(dialog.style.top).toBe(''); + expect(dialog.style.bottom).toBe('243px'); + } finally { + Element.prototype.getBoundingClientRect = originalRect; + } + }); + 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 2c8436b23..2d189aaf7 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -383,22 +383,30 @@ export function CustomDatePicker({ position: 'fixed', // reanchor ticks on scroll / resize / keyboard so this recomputes // against a fresh rect instead of the one measured on open (#1999). - ...((): { top: number; left: number } => { + ...((): { top?: number; bottom?: number; left: number } => { void reanchor; const r = ref.current?.getBoundingClientRect(); if (!r) return { top: 0, left: 0 }; const w = 268, pad = 8, + // Only used to decide *whether* the popup fits below the trigger — + // its real height varies with the month's row count and the view + // (days/months/years), so it can't be known before it renders. h = 360; const vw = window.innerWidth; const vh = window.visualViewport?.height ?? window.innerHeight; let left = r.left; - let top = r.bottom + 4; if (left + w > vw - pad) left = Math.max(pad, vw - w - pad); - if (top + h > vh - pad) top = r.top - h - 4; - top = Math.max(pad, Math.min(top, vh - h - pad)); if (vw < 360) left = Math.max(pad, (vw - w) / 2); - return { top, left }; + if (r.bottom + 4 + h <= vh - pad) return { top: r.bottom + 4, 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 + // short modal (e.g. the settle-up form) that gap was big enough to + // 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 }; })(), zIndex: 99999, background: 'var(--bg-card)',