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)',