mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
fix(client): keep a flipped date-picker popup anchored to its trigger
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.
This commit is contained in:
@@ -577,9 +577,12 @@ describe('CustomDatePicker branches', () => {
|
||||
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
openCalendar();
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user