From df56790c9bc81fa45b1e774d95c373c8d3651163 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 01:00:57 -0700 Subject: [PATCH] FN-8676: fix Quick Add upward menu anchoring Keep Quick Add portal menus attached to their triggers when they open upward. - Position upward menus with bottom anchors rather than height-capped top offsets. - Apply shared positioning to Quick Add pickers and the custom model dropdown. - Add desktop and mobile regression coverage and a patch changeset. Files changed: .changeset/quick-add-upward-menu-anchor.md | 7 ++++ .../app/components/CustomModelDropdown.tsx | 18 ++++++-- .../dashboard/app/components/QuickEntryBox.tsx | 48 +++++++++++++++------- .../__tests__/CustomModelDropdown.test.tsx | 40 +++++++++++++++--- .../components/__tests__/QuickEntryBox.test.tsx | 46 ++++++++++++++++++--- .../app/utils/__tests__/fixedMenuPosition.test.ts | 12 +++--- packages/dashboard/app/utils/fixedMenuPosition.ts | 22 ++++++---- 7 files changed, 151 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-8676 Fusion-Task-Lineage: e48b4b2c-9675-42c6-8712-83cb4a0ca3d8 Co-authored-by: Fusion (runfusion.ai) --- .changeset/quick-add-upward-menu-anchor.md | 7 +++ .../app/components/CustomModelDropdown.tsx | 18 +++++-- .../app/components/QuickEntryBox.tsx | 48 +++++++++++++------ .../__tests__/CustomModelDropdown.test.tsx | 40 ++++++++++++++-- .../__tests__/QuickEntryBox.test.tsx | 46 ++++++++++++++++-- .../utils/__tests__/fixedMenuPosition.test.ts | 12 ++--- .../dashboard/app/utils/fixedMenuPosition.ts | 22 +++++---- 7 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 .changeset/quick-add-upward-menu-anchor.md diff --git a/.changeset/quick-add-upward-menu-anchor.md b/.changeset/quick-add-upward-menu-anchor.md new file mode 100644 index 0000000000..68b9623a76 --- /dev/null +++ b/.changeset/quick-add-upward-menu-anchor.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep upward Quick Add model and priority menus anchored to their trigger. +category: fix +dev: Portal menus now use bottom anchoring when opening upward. diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index d6d4618746..b8d0c3dbb1 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -42,7 +42,8 @@ export interface CustomModelDropdownProps { } interface DropdownPosition { - top: number; + top: number | null; + bottom: number | null; left: number; width: number; maxHeight: number; @@ -362,12 +363,22 @@ export function CustomModelDropdown({ Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - dropdownWidth, ) + offsetLeft; + /* + FNXC:ModelDropdown 2026-08-01-07:11: + The model menu's maxHeight includes a 160px scroll floor, so upward top placement based on that + cap separates short model lists from their trigger. Anchor the bottom instead; the visual-viewport + offset preserves the existing effective-viewport coordinate conversion for keyboard and zoom cases. + */ const top = openUpward - ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) + ? null : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight); + const bottom = openUpward + ? viewportHeight + offsetTop - rect.top + gap + : null; setDropdownPosition({ top, + bottom, left, width: dropdownWidth, maxHeight, @@ -593,7 +604,8 @@ export function CustomModelDropdown({ data-menu-width={menuWidth} onKeyDown={handleKeyDown} style={{ - top: `${dropdownPosition.top}px`, + top: dropdownPosition.bottom === null ? `${dropdownPosition.top}px` : "auto", + bottom: dropdownPosition.bottom === null ? undefined : `${dropdownPosition.bottom}px`, left: `${dropdownPosition.left}px`, width: `${dropdownPosition.width}px`, maxHeight: `${dropdownPosition.maxHeight}px`, diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 1b309f7bec..a84dd9e835 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -221,14 +221,20 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const nodePickerPortalRef = useRef(null); const priorityPickerRef = useRef(null); const priorityPickerPortalRef = useRef(null); - const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); - const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); - const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); - const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); + /* + FNXC:QuickAddMenuAnchor 2026-08-01-07:11: + Preserve both shared-helper vertical anchors in Quick Add state. Portal styles must use `bottom` + with `top: auto` upward, because a natural-height menu cannot remain attached when top is derived + from its independent max-height scroll cap. + */ + const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); + const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); + const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); + const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); // Dependency dropdown portal refs and state const depTriggerRef = useRef(null); const depDropdownPortalRef = useRef(null); - const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); + const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); const [portalRoot] = useState(() => typeof document !== "undefined" ? document.body : null, ); @@ -236,7 +242,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const workflowPickerRef = useRef(null); const workflowTriggerRef = useRef(null); const workflowPickerPortalRef = useRef(null); - const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); + const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null); const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId }); const [showWorkflowPicker, setShowWorkflowPicker] = useState(false); /* @@ -1106,9 +1112,9 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai /* 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. + layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or deriving + upward `top` from a height cap, made short menus float too high; upward portals consume `bottom` + and `top: auto` so their rendered bottom remains attached regardless of content height. */ const updateModelMenuPosition = useCallback(() => { const trigger = modelTriggerRef.current; @@ -1136,6 +1142,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setModelMenuPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1173,6 +1180,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setWorkflowPickerPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1206,6 +1214,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setDepDropdownPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1238,6 +1247,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setAgentPickerPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1270,6 +1280,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setNodePickerPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1292,6 +1303,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai }); setPriorityPickerPosition({ top: position.top, + bottom: position.bottom, left: position.left, width: position.width, maxHeight: position.maxHeight, @@ -1801,7 +1813,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai data-testid="quick-entry-workflow-menu" style={{ position: "fixed", - top: `${workflowPickerPosition.top}px`, + top: workflowPickerPosition.bottom === null ? `${workflowPickerPosition.top}px` : "auto", + bottom: workflowPickerPosition.bottom === null ? undefined : `${workflowPickerPosition.bottom}px`, left: `${workflowPickerPosition.left}px`, width: `${workflowPickerPosition.width}px`, maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined, @@ -1929,7 +1942,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai onMouseDown={(e) => e.preventDefault()} style={{ position: "fixed", - top: `${depDropdownPosition.top}px`, + top: depDropdownPosition.bottom === null ? `${depDropdownPosition.top}px` : "auto", + bottom: depDropdownPosition.bottom === null ? undefined : `${depDropdownPosition.bottom}px`, left: `${depDropdownPosition.left}px`, width: `${depDropdownPosition.width}px`, maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined, @@ -2032,7 +2046,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai onMouseDown={(e) => e.preventDefault()} style={{ position: "fixed", - top: `${nodePickerPosition.top}px`, + top: nodePickerPosition.bottom === null ? `${nodePickerPosition.top}px` : "auto", + bottom: nodePickerPosition.bottom === null ? undefined : `${nodePickerPosition.bottom}px`, left: `${nodePickerPosition.left}px`, width: `${nodePickerPosition.width}px`, maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined, @@ -2108,7 +2123,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai onMouseDown={(e) => e.preventDefault()} style={{ position: "fixed", - top: `${agentPickerPosition.top}px`, + top: agentPickerPosition.bottom === null ? `${agentPickerPosition.top}px` : "auto", + bottom: agentPickerPosition.bottom === null ? undefined : `${agentPickerPosition.bottom}px`, left: `${agentPickerPosition.left}px`, width: `${agentPickerPosition.width}px`, maxHeight: agentPickerPosition.maxHeight ? `${agentPickerPosition.maxHeight}px` : undefined, @@ -2293,7 +2309,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai onMouseDown={(e) => e.preventDefault()} style={{ position: "fixed", - top: `${priorityPickerPosition.top}px`, + top: priorityPickerPosition.bottom === null ? `${priorityPickerPosition.top}px` : "auto", + bottom: priorityPickerPosition.bottom === null ? undefined : `${priorityPickerPosition.bottom}px`, left: `${priorityPickerPosition.left}px`, width: `${priorityPickerPosition.width}px`, maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined, @@ -2369,7 +2386,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai data-testid="model-nested-menu" style={{ position: "fixed", - top: `${modelMenuPosition.top}px`, + top: modelMenuPosition.bottom === null ? `${modelMenuPosition.top}px` : "auto", + bottom: modelMenuPosition.bottom === null ? undefined : `${modelMenuPosition.bottom}px`, left: `${modelMenuPosition.left}px`, width: `${modelMenuPosition.width}px`, maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined, diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index 4ed9c022c5..ae96a456e7 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -572,10 +572,41 @@ describe("CustomModelDropdown", () => { const portal = await screen.findByTestId("model-combobox-portal"); expect(portal.style.left).toBe("239px"); expect(portal.style.width).toBe("120px"); - expect(portal.style.top).toBe("196px"); + expect(portal.style.top).toBe("auto"); + expect(portal.style.bottom).toBe("111px"); expect(portal.style.maxHeight).toBe("360px"); }); + it.each([ + { name: "desktop", width: 1024, height: 760 }, + { name: "mobile", width: 375, height: 760 }, + ])("bottom-anchors an empty model list upward on $name", async ({ width, height }) => { + const user = userEvent.setup(); + vi.spyOn(window, "innerWidth", "get").mockReturnValue(width); + vi.spyOn(window, "innerHeight", "get").mockReturnValue(height); + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: width <= 768 && query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + } as MediaQueryList)); + + render(); + const trigger = screen.getByRole("button", { name: "Empty Model" }); + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + top: 700, bottom: 728, left: 24, width: 120, right: 144, height: 28, x: 24, y: 700, toJSON: () => ({}), + }); + + await user.click(trigger); + const portal = await screen.findByTestId("model-combobox-portal"); + expect(portal.style.top).toBe("auto"); + expect(portal.style.bottom).toBe(`${height - 700 + 4}px`); + }); + describe("Readable menu sizing", () => { const setupBoundingRectMock = (rectValues: DOMRect) => { const originalGetBCR = Element.prototype.getBoundingClientRect; @@ -1070,10 +1101,9 @@ describe("CustomModelDropdown", () => { await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); - const top = parseFloat(portal.style.top); - - // Should position upward: rect.top - estimatedHeight - 4 = 710 - 320 - 4 = 386 - expect(top).toBe(386); + // Upward placement must be independent of the max-height scroll cap. + expect(portal.style.top).toBe("auto"); + expect(portal.style.bottom).toBe("94px"); } finally { restore(); Object.defineProperty(window, "innerHeight", { diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 66cbcd6f69..dda4bed671 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -3126,13 +3126,11 @@ describe("QuickEntryBox", () => { 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); + // Bottom anchoring stays attached even when the menu's natural list is shorter than maxHeight. + expect(dropdown.style.top).toBe("auto"); + expect(dropdown.style.bottom).toBe(`${viewportHeight - 150 + gap}px`); expect(maxHeight).toBeLessThanOrEqual(150 - 16 - gap); }); @@ -4466,6 +4464,44 @@ describe("QuickEntryBox", () => { expect(menu.style.width).toBeTruthy(); }); + it.each([ + { name: "desktop", width: 1280, height: 760 }, + { name: "mobile", width: 375, height: 760 }, + ])("bottom-anchors short priority and populated model menus upward on $name", ({ width, height }) => { + const widthDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientWidth"); + const heightDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientHeight"); + Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: width }); + Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: height }); + + try { + renderQuickEntryBox({}); + expandQuickEntry(); + + const priorityTrigger = screen.getByTestId("quick-entry-priority-button"); + vi.spyOn(priorityTrigger, "getBoundingClientRect").mockReturnValue({ + top: 700, bottom: 728, left: 24, width: 80, right: 104, height: 28, x: 24, y: 700, toJSON: () => ({}), + }); + openPriorityMenu(); + const priorityMenu = screen.getByTestId("quick-entry-priority-option-normal").closest(".priority-picker-dropdown--portal") as HTMLElement; + expect(priorityMenu.style.top).toBe("auto"); + expect(priorityMenu.style.bottom).toBe(`${height - 700 + 4}px`); + + const modelTrigger = screen.getByTestId("quick-entry-models"); + vi.spyOn(modelTrigger, "getBoundingClientRect").mockReturnValue({ + top: 700, bottom: 728, left: 24, width: 80, right: 104, height: 28, x: 24, y: 700, toJSON: () => ({}), + }); + openModelMenu(); + const modelMenu = screen.getByTestId("model-nested-menu"); + expect(modelMenu.style.top).toBe("auto"); + expect(modelMenu.style.bottom).toBe(`${height - 700 + 4}px`); + } finally { + if (widthDescriptor) Object.defineProperty(document.documentElement, "clientWidth", widthDescriptor); + else delete (document.documentElement as { clientWidth?: number }).clientWidth; + if (heightDescriptor) Object.defineProperty(document.documentElement, "clientHeight", heightDescriptor); + else delete (document.documentElement as { clientHeight?: number }).clientHeight; + } + }); + it("does not close model menu when clicking inside CustomModelDropdown portal", () => { renderQuickEntryBox({}); expandQuickEntry(); diff --git a/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts b/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts index 2cb4358596..513b49f106 100644 --- a/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts +++ b/packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts @@ -29,6 +29,7 @@ describe("computeFixedMenuPosition", () => { expect(position.openUpward).toBe(false); expect(position.top).toBe(baseTrigger.bottom + 4); + expect(position.bottom).toBeNull(); expect(position.left).toBe(baseTrigger.left); expect(position.width).toBe(280); expect(position.maxHeight).toBe(320); @@ -47,8 +48,8 @@ describe("computeFixedMenuPosition", () => { }); 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.top).toBeNull(); + expect(position.bottom).toBe(600 - trigger.top + 4); expect(position.maxHeight).toBeLessThanOrEqual(320); expect(position.maxHeight).toBeGreaterThan(0); }); @@ -73,10 +74,9 @@ describe("computeFixedMenuPosition", () => { // 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); + expect(position.top).toBeNull(); + // The CSS bottom offset is height-independent, even when maxHeight shrinks. + expect(position.bottom).toBe(viewportHeight - trigger.top + gap); }); it("shrinks maxHeight when opening downward into a short viewport instead of lifting top off the trigger", () => { diff --git a/packages/dashboard/app/utils/fixedMenuPosition.ts b/packages/dashboard/app/utils/fixedMenuPosition.ts index bda5b813dc..945e3974d7 100644 --- a/packages/dashboard/app/utils/fixedMenuPosition.ts +++ b/packages/dashboard/app/utils/fixedMenuPosition.ts @@ -7,9 +7,9 @@ Portaled `position: fixed` menus (Quick Add Deps and sibling pickers) must stay (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. +Anchor-first: place the menu immediately below the trigger or anchor its bottom immediately above it, +then shrink `maxHeight` to the real free space. Computing upward `top` from a preferred/clamped +height detached short menus; never clamp placement to preserve a preferred/min height floor. */ export interface FixedMenuTriggerRect { @@ -33,7 +33,8 @@ export interface FixedMenuPositionInput { } export interface FixedMenuPosition { - top: number; + top: number | null; + bottom: number | null; left: number; width: number; maxHeight: number; @@ -88,13 +89,18 @@ export function computeFixedMenuPosition(input: FixedMenuPositionInput): FixedMe 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 }; + /* + FNXC:QuickAddMenuAnchor 2026-08-01-07:11: + Upward Quick Add menus must anchor their bottom edge to the trigger because maxHeight is only a + scroll cap. Positioning from a preferred/clamped height detaches short option lists; bottom + anchoring keeps every rendered height exactly `gap` above the trigger. + */ + const bottom = input.viewportHeight - input.triggerRect.top + gap; + return { top: null, bottom, 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 }; + return { top, bottom: null, left, width, maxHeight, openUpward: false }; }