From 10ebd0e025b6113504dbf9ea80b3138f60e8ca4e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 21:01:25 -0700 Subject: [PATCH] fix(dashboard): keep quick-entry portal menus attached when space is tight Extract shared fixed-menu positioning so deps/agent/node/priority pickers clamp max-height without detaching from the trigger when free space is shorter than the preferred dropdown height. --- .../app/components/QuickEntryBox.tsx | 370 +++++------------- .../__tests__/QuickEntryBox.test.tsx | 47 +++ .../utils/__tests__/fixedMenuPosition.test.ts | 136 +++++++ .../dashboard/app/utils/fixedMenuPosition.ts | 100 +++++ 4 files changed, 388 insertions(+), 265 deletions(-) create mode 100644 packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts create mode 100644 packages/dashboard/app/utils/fixedMenuPosition.ts diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 9723ad6a22..e7fe83b12b 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -22,6 +22,7 @@ import { WorkflowIcon } from "./WorkflowIcon"; import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart"; +import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition"; const STORAGE_KEY = "kb-quick-entry-text"; const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([ @@ -1102,361 +1103,200 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai ); }, []); - const getEffectiveViewport = useCallback(() => { - const vv = window.visualViewport; - if (vv && vv.width > 0 && vv.height > 0) { - return { - width: vv.width, - height: vv.height, - offsetTop: vv.offsetTop, - offsetLeft: vv.offsetLeft, - }; - } - - return { - width: window.innerWidth, - height: window.innerHeight, - offsetTop: 0, - offsetLeft: 0, - }; - }, []); - + /* + FNXC:QuickAddDepsMenu 2026-07-25-12:00: + All Quick Add portaled menus (Deps, Models, workflow, agent, node, priority) share anchor-first + layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or clamping + top away from the trigger to preserve a min height floor, made Deps (and siblings) float too high + and unattached when free space was tight. + */ const updateModelMenuPosition = useCallback(() => { const trigger = modelTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; const isMobile = viewportWidth <= 768; - const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 360) : Math.min(viewportHeight * 0.5, 360); - - const preferredDesktopWidth = Math.max(rect.width * 1.35, 320); const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 360) - : preferredDesktopWidth; + : Math.max(rect.width * 1.35, 320); - const width = Math.min( + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, preferredWidth, - Math.max(viewportWidth - horizontalPadding * 2, 240), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 160, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setModelMenuPosition({ - top, - left, - width, - maxHeight, + preferredHeight, + minWidth: 240, + horizontalPadding, }); - }, [getEffectiveViewport]); + setModelMenuPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); const updateWorkflowPickerPosition = useCallback(() => { const trigger = workflowTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = Math.min(viewportHeight * (isMobile ? 0.6 : 0.5), 360); /* FNXC:QuickAddWorkflow 2026-06-30-16:16: - The workflow menu is wider than its compact trigger, so portal and clamp it against the visual viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow. + The workflow menu is wider than its compact trigger, so portal and clamp it against the layout viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow. + + FNXC:QuickAddDepsMenu 2026-07-25-12:00: + Width still expands past the compact trigger; vertical placement uses the shared anchor-first helper so the menu stays attached like Deps/Models. */ const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 448) : Math.min(Math.max(rect.width * 3, 448), 512); - const width = Math.min( + + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, preferredWidth, - Math.max(viewportWidth - horizontalPadding * 2, 240), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 180); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 180); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 180, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setWorkflowPickerPosition({ top, left, width, maxHeight }); - }, [getEffectiveViewport]); + preferredHeight, + minWidth: 240, + horizontalPadding, + }); + setWorkflowPickerPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); const updateDepDropdownPosition = useCallback(() => { const trigger = depTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; const isMobile = viewportWidth <= 768; - const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); - // Wider dropdown for dependency selection - easier to read task names const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 360) : Math.max(rect.width, 280); - const width = Math.min( + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, preferredWidth, - Math.max(viewportWidth - horizontalPadding * 2, 240), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 200); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 200); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 200, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setDepDropdownPosition({ - top, - left, - width, - maxHeight, + preferredHeight, + minWidth: 240, + horizontalPadding, }); - }, [getEffectiveViewport]); + setDepDropdownPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); const updateAgentPickerPosition = useCallback(() => { const trigger = agentPickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; const isMobile = viewportWidth <= 768; - const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); - const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 280) : Math.max(rect.width, 240); - const width = Math.min( + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, preferredWidth, - Math.max(viewportWidth - horizontalPadding * 2, 200), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 160, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setAgentPickerPosition({ - top, - left, - width, - maxHeight, + preferredHeight, + minWidth: 200, + horizontalPadding, }); - }, [getEffectiveViewport]); + setAgentPickerPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); const updateNodePickerPosition = useCallback(() => { const trigger = nodePickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; const isMobile = viewportWidth <= 768; - const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); - const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 280) : Math.max(rect.width, 240); - const width = Math.min( + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, preferredWidth, - Math.max(viewportWidth - horizontalPadding * 2, 200), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 160, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setNodePickerPosition({ - top, - left, - width, - maxHeight, + preferredHeight, + minWidth: 200, + horizontalPadding, }); - }, [getEffectiveViewport]); + setNodePickerPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); const updatePriorityPickerPosition = useCallback(() => { const trigger = priorityPickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); - const horizontalPadding = 16; - const verticalPadding = 16; - const gap = 4; - const preferredHeight = 220; - const width = Math.min( - Math.max(rect.width, 200), - Math.max(viewportWidth - horizontalPadding * 2, 200), - ); - - const triggerTop = rect.top - offsetTop; - const triggerBottom = rect.bottom - offsetTop; - const triggerLeft = rect.left - offsetLeft; - - const spaceBelow = viewportHeight - triggerBottom; - const spaceAbove = triggerTop; - const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); - const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); - const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; - const maxHeight = Math.max( - Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), - 160, - ); - - const left = Math.min( - Math.max(triggerLeft, horizontalPadding), - viewportWidth - horizontalPadding - width, - ) + offsetLeft; - - const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) - : Math.min( - triggerBottom + gap + offsetTop, - viewportHeight + offsetTop - verticalPadding - maxHeight, - ); - - setPriorityPickerPosition({ - top, - left, - width, - maxHeight, + const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize(); + const position = computeFixedMenuPosition({ + triggerRect: rect, + viewportWidth, + viewportHeight, + preferredWidth: Math.max(rect.width, 200), + preferredHeight: 220, + minWidth: 200, }); - }, [getEffectiveViewport]); + setPriorityPickerPosition({ + top: position.top, + left: position.left, + width: position.width, + maxHeight: position.maxHeight, + }); + }, []); // Keep model menu portal anchored during scroll/resize useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index d88da27cd7..05a407246b 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -3075,6 +3075,53 @@ describe("QuickEntryBox", () => { expect(dropdown?.classList.contains("dep-dropdown--portal")).toBe(true); }); + /* + FNXC:QuickAddDepsMenu 2026-07-25-12:00: + Symptom verification: Deps portal menu must open attached to the Deps button, not float high + with a gap when free space is shorter than the preferred dropdown height. + */ + it("anchors the deps portal menu to the Deps button when free space is tight", () => { + const viewportWidth = 390; + const viewportHeight = 220; + vi.spyOn(window, "innerWidth", "get").mockReturnValue(viewportWidth); + vi.spyOn(window, "innerHeight", "get").mockReturnValue(viewportHeight); + // Prefer window.inner* in this case: leave documentElement client dims at jsdom 0 so layout + // size falls back cleanly and we do not leak clientWidth into later suite tests. + renderQuickEntryBox({}); + expandQuickEntry(); + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Task with deps" } }); + + const depsButton = screen.getByTestId("quick-entry-deps"); + // Trigger near the bottom of a short viewport — preferred 320px height cannot fit below or fully above. + vi.spyOn(depsButton, "getBoundingClientRect").mockReturnValue({ + x: 20, + y: 150, + width: 64, + height: 32, + top: 150, + right: 84, + bottom: 182, + left: 20, + toJSON: () => ({}), + } as DOMRect); + + openDepsMenu(); + + const dropdown = document.querySelector(".dep-dropdown") as HTMLElement; + expect(dropdown).toBeTruthy(); + expect(dropdown.classList.contains("dep-dropdown--portal")).toBe(true); + expect(dropdown.style.position).toBe("fixed"); + + const top = parseFloat(dropdown.style.top); + const maxHeight = parseFloat(dropdown.style.maxHeight); + const gap = 4; + // Menu bottom edge must sit `gap` above the trigger top (open upward, attached). + expect(top + maxHeight).toBeCloseTo(150 - gap, 5); + // Must not float at the old detached clamp (top ≈ 16 with maxHeight 200 leaving a large gap). + expect(top + maxHeight + gap).toBeCloseTo(150, 5); + expect(maxHeight).toBeLessThanOrEqual(150 - 16 - gap); + }); + it("shows long task titles without aggressive truncation", () => { const longTitleTask: Task = { id: "FN-999", diff --git a/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts b/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts new file mode 100644 index 0000000000..2cb4358596 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { computeFixedMenuPosition, getLayoutViewportSize } from "../fixedMenuPosition"; + +/* +FNXC:QuickAddDepsMenu 2026-07-25-12:00: +Regression coverage for the Quick Add Deps (and sibling portal) anchor-first geometry: +menus must stay attached to the trigger and shrink height into free space instead of floating +too high when the preferred height does not fit. +*/ + +describe("computeFixedMenuPosition", () => { + const baseTrigger = { + top: 200, + bottom: 232, + left: 100, + width: 80, + }; + + it("attaches the menu immediately below the trigger when space below is ample", () => { + const position = computeFixedMenuPosition({ + triggerRect: baseTrigger, + viewportWidth: 1000, + viewportHeight: 800, + preferredWidth: 280, + preferredHeight: 320, + minWidth: 240, + gap: 4, + }); + + expect(position.openUpward).toBe(false); + expect(position.top).toBe(baseTrigger.bottom + 4); + expect(position.left).toBe(baseTrigger.left); + expect(position.width).toBe(280); + expect(position.maxHeight).toBe(320); + }); + + it("opens upward attached to the trigger top when space below is short and space above is larger", () => { + const trigger = { top: 500, bottom: 532, left: 40, width: 72 }; + const position = computeFixedMenuPosition({ + triggerRect: trigger, + viewportWidth: 800, + viewportHeight: 600, + preferredWidth: 280, + preferredHeight: 320, + gap: 4, + verticalPadding: 16, + }); + + expect(position.openUpward).toBe(true); + // Menu bottom edge is gap above the trigger; top = trigger.top - gap - maxHeight. + expect(position.top + position.maxHeight).toBe(trigger.top - 4); + expect(position.maxHeight).toBeLessThanOrEqual(320); + expect(position.maxHeight).toBeGreaterThan(0); + }); + + it("does not float the menu too high when preferred height exceeds free space above (Deps symptom)", () => { + // Trigger near the bottom; little room below, limited room above vs preferred 320. + const trigger = { top: 150, bottom: 182, left: 20, width: 64 }; + const viewportHeight = 220; + const gap = 4; + const verticalPadding = 16; + const position = computeFixedMenuPosition({ + triggerRect: trigger, + viewportWidth: 390, + viewportHeight, + preferredWidth: 280, + preferredHeight: 320, + gap, + verticalPadding, + }); + + // Available above after padding+gap: 150 - 16 - 4 = 130. + // Old math floored maxHeight to 200 and clamped top to verticalPadding (16), detaching the menu. + expect(position.openUpward).toBe(true); + expect(position.maxHeight).toBe(130); + expect(position.top).toBe(trigger.top - gap - position.maxHeight); + expect(position.top).toBe(16); // verticalPadding + // Still attached: menu bottom + gap === trigger top + expect(position.top + position.maxHeight + gap).toBe(trigger.top); + }); + + it("shrinks maxHeight when opening downward into a short viewport instead of lifting top off the trigger", () => { + const trigger = { top: 40, bottom: 72, left: 16, width: 80 }; + const position = computeFixedMenuPosition({ + triggerRect: trigger, + viewportWidth: 800, + viewportHeight: 200, + preferredWidth: 280, + preferredHeight: 320, + gap: 4, + verticalPadding: 16, + }); + + expect(position.openUpward).toBe(false); + expect(position.top).toBe(trigger.bottom + 4); + // spaceBelow = 200 - 72 - 16 - 4 = 108 + expect(position.maxHeight).toBe(108); + }); + + it("clamps a right-side trigger menu inside horizontal padding without changing vertical attachment", () => { + const trigger = { top: 100, bottom: 132, left: 720, width: 64 }; + const position = computeFixedMenuPosition({ + triggerRect: trigger, + viewportWidth: 800, + viewportHeight: 720, + preferredWidth: 448, + preferredHeight: 320, + minWidth: 240, + horizontalPadding: 16, + gap: 4, + }); + + expect(position.top).toBe(trigger.bottom + 4); + expect(position.left).toBeGreaterThanOrEqual(16); + expect(position.left + position.width).toBeLessThanOrEqual(800 - 16); + expect(position.width).toBeGreaterThan(64); + }); +}); + +describe("getLayoutViewportSize", () => { + it("prefers documentElement client dimensions over window.inner*", () => { + const size = getLayoutViewportSize( + { documentElement: { clientWidth: 390, clientHeight: 720 } as HTMLElement }, + { innerWidth: 1000, innerHeight: 900 }, + ); + expect(size).toEqual({ width: 390, height: 720 }); + }); + + it("falls back to window.inner* when client dimensions are zero", () => { + const size = getLayoutViewportSize( + { documentElement: { clientWidth: 0, clientHeight: 0 } as HTMLElement }, + { innerWidth: 1024, innerHeight: 768 }, + ); + expect(size).toEqual({ width: 1024, height: 768 }); + }); +}); diff --git a/packages/dashboard/app/utils/fixedMenuPosition.ts b/packages/dashboard/app/utils/fixedMenuPosition.ts new file mode 100644 index 0000000000..bda5b813dc --- /dev/null +++ b/packages/dashboard/app/utils/fixedMenuPosition.ts @@ -0,0 +1,100 @@ +/* +FNXC:QuickAddDepsMenu 2026-07-25-12:00: +Portaled `position: fixed` menus (Quick Add Deps and sibling pickers) must stay attached to their trigger. +`getBoundingClientRect()` and fixed CSS both use the layout viewport, so sizing must use +`document.documentElement.clientWidth/clientHeight` (or `window.inner*`) and must never mix in +`visualViewport` width/height/offset — that mix detaches menus under pinch-zoom or an open keyboard +(same failure class as the TaskDetail Activity menu fix). + +FNXC:QuickAddDepsMenu 2026-07-25-12:00: +Anchor-first: place the menu immediately below (or above) the trigger, then shrink `maxHeight` to the +real free space. Never clamp `top` away from the trigger to preserve a preferred/min height floor — +that produced the Deps menu floating too high and unattached when space was tight. +*/ + +export interface FixedMenuTriggerRect { + top: number; + bottom: number; + left: number; + width: number; +} + +export interface FixedMenuPositionInput { + triggerRect: FixedMenuTriggerRect; + viewportWidth: number; + viewportHeight: number; + preferredWidth: number; + preferredHeight: number; + /** Soft floor for width when the viewport is wide enough; never forces horizontal overflow. */ + minWidth?: number; + horizontalPadding?: number; + verticalPadding?: number; + gap?: number; +} + +export interface FixedMenuPosition { + top: number; + left: number; + width: number; + maxHeight: number; + openUpward: boolean; +} + +export interface LayoutViewportSize { + width: number; + height: number; +} + +/** + * Layout-viewport size for `position: fixed` portal menus. + * Prefer `documentElement.client*` so zoom/keyboard visual-viewport drift cannot shove anchors. + */ +export function getLayoutViewportSize( + doc: Pick | null | undefined = typeof document !== "undefined" ? document : null, + win: Pick | null | undefined = typeof window !== "undefined" ? window : null, +): LayoutViewportSize { + const docEl = doc?.documentElement; + return { + width: docEl?.clientWidth || win?.innerWidth || 0, + height: docEl?.clientHeight || win?.innerHeight || 0, + }; +} + +/** + * Compute fixed portal menu geometry that stays attached to the trigger. + * Shrinks height into free space instead of sliding the menu off the trigger. + */ +export function computeFixedMenuPosition(input: FixedMenuPositionInput): FixedMenuPosition { + const horizontalPadding = input.horizontalPadding ?? 16; + const verticalPadding = input.verticalPadding ?? 16; + const gap = input.gap ?? 4; + const minWidth = input.minWidth ?? 0; + + const maxViewportWidth = Math.max(input.viewportWidth - horizontalPadding * 2, 0); + const width = Math.min( + Math.max(input.preferredWidth, minWidth > 0 ? Math.min(minWidth, maxViewportWidth) : 0), + maxViewportWidth > 0 ? maxViewportWidth : Math.max(input.preferredWidth, minWidth), + ); + + // Keep the left edge under the trigger; shift only enough to stay inside horizontal padding. + const left = Math.max( + horizontalPadding, + Math.min(input.triggerRect.left, Math.max(horizontalPadding, input.viewportWidth - horizontalPadding - width)), + ); + + const spaceBelow = input.viewportHeight - input.triggerRect.bottom - verticalPadding - gap; + const spaceAbove = input.triggerRect.top - verticalPadding - gap; + const openUpward = spaceBelow < input.preferredHeight && spaceAbove > spaceBelow; + + if (openUpward) { + const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceAbove)); + // Bottom edge of the menu sits `gap` above the trigger top — always attached. + const top = input.triggerRect.top - gap - maxHeight; + return { top, left, width, maxHeight, openUpward: true }; + } + + const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceBelow)); + // Top edge sits `gap` below the trigger bottom — always attached. + const top = input.triggerRect.bottom + gap; + return { top, left, width, maxHeight, openUpward: false }; +}