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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 01:00:57 -07:00
parent 1e83dcceec
commit df56790c9b
7 changed files with 151 additions and 42 deletions

View File

@@ -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.

View File

@@ -42,7 +42,8 @@ export interface CustomModelDropdownProps {
} }
interface DropdownPosition { interface DropdownPosition {
top: number; top: number | null;
bottom: number | null;
left: number; left: number;
width: number; width: number;
maxHeight: number; maxHeight: number;
@@ -362,12 +363,22 @@ export function CustomModelDropdown({
Math.max(triggerLeft, horizontalPadding), Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - dropdownWidth, viewportWidth - horizontalPadding - dropdownWidth,
) + offsetLeft; ) + 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 const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) ? null
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight); : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
const bottom = openUpward
? viewportHeight + offsetTop - rect.top + gap
: null;
setDropdownPosition({ setDropdownPosition({
top, top,
bottom,
left, left,
width: dropdownWidth, width: dropdownWidth,
maxHeight, maxHeight,
@@ -593,7 +604,8 @@ export function CustomModelDropdown({
data-menu-width={menuWidth} data-menu-width={menuWidth}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ 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`, left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`, width: `${dropdownPosition.width}px`,
maxHeight: `${dropdownPosition.maxHeight}px`, maxHeight: `${dropdownPosition.maxHeight}px`,

View File

@@ -221,14 +221,20 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const nodePickerPortalRef = useRef<HTMLDivElement>(null); const nodePickerPortalRef = useRef<HTMLDivElement>(null);
const priorityPickerRef = useRef<HTMLDivElement>(null); const priorityPickerRef = useRef<HTMLDivElement>(null);
const priorityPickerPortalRef = useRef<HTMLDivElement>(null); const priorityPickerPortalRef = useRef<HTMLDivElement>(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); FNXC:QuickAddMenuAnchor 2026-08-01-07:11:
const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); Preserve both shared-helper vertical anchors in Quick Add state. Portal styles must use `bottom`
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); 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 // Dependency dropdown portal refs and state
const depTriggerRef = useRef<HTMLButtonElement>(null); const depTriggerRef = useRef<HTMLButtonElement>(null);
const depDropdownPortalRef = useRef<HTMLDivElement>(null); const depDropdownPortalRef = useRef<HTMLDivElement>(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<HTMLElement | null>(() => const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null, typeof document !== "undefined" ? document.body : null,
); );
@@ -236,7 +242,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const workflowPickerRef = useRef<HTMLDivElement>(null); const workflowPickerRef = useRef<HTMLDivElement>(null);
const workflowTriggerRef = useRef<HTMLButtonElement>(null); const workflowTriggerRef = useRef<HTMLButtonElement>(null);
const workflowPickerPortalRef = useRef<HTMLDivElement>(null); const workflowPickerPortalRef = useRef<HTMLDivElement>(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 previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false); const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
/* /*
@@ -1106,9 +1112,9 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
/* /*
FNXC:QuickAddDepsMenu 2026-07-25-12:00: FNXC:QuickAddDepsMenu 2026-07-25-12:00:
All Quick Add portaled menus (Deps, Models, workflow, agent, node, priority) share anchor-first All Quick Add portaled menus (Deps, Models, workflow, agent, node, priority) share anchor-first
layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or clamping layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or deriving
top away from the trigger to preserve a min height floor, made Deps (and siblings) float too high upward `top` from a height cap, made short menus float too high; upward portals consume `bottom`
and unattached when free space was tight. and `top: auto` so their rendered bottom remains attached regardless of content height.
*/ */
const updateModelMenuPosition = useCallback(() => { const updateModelMenuPosition = useCallback(() => {
const trigger = modelTriggerRef.current; const trigger = modelTriggerRef.current;
@@ -1136,6 +1142,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setModelMenuPosition({ setModelMenuPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1173,6 +1180,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setWorkflowPickerPosition({ setWorkflowPickerPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1206,6 +1214,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setDepDropdownPosition({ setDepDropdownPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1238,6 +1247,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setAgentPickerPosition({ setAgentPickerPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1270,6 +1280,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setNodePickerPosition({ setNodePickerPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1292,6 +1303,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
}); });
setPriorityPickerPosition({ setPriorityPickerPosition({
top: position.top, top: position.top,
bottom: position.bottom,
left: position.left, left: position.left,
width: position.width, width: position.width,
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
@@ -1801,7 +1813,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
data-testid="quick-entry-workflow-menu" data-testid="quick-entry-workflow-menu"
style={{ style={{
position: "fixed", 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`, left: `${workflowPickerPosition.left}px`,
width: `${workflowPickerPosition.width}px`, width: `${workflowPickerPosition.width}px`,
maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined, maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined,
@@ -1929,7 +1942,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
style={{ style={{
position: "fixed", 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`, left: `${depDropdownPosition.left}px`,
width: `${depDropdownPosition.width}px`, width: `${depDropdownPosition.width}px`,
maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined, maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined,
@@ -2032,7 +2046,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
style={{ style={{
position: "fixed", 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`, left: `${nodePickerPosition.left}px`,
width: `${nodePickerPosition.width}px`, width: `${nodePickerPosition.width}px`,
maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined, maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined,
@@ -2108,7 +2123,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
style={{ style={{
position: "fixed", 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`, left: `${agentPickerPosition.left}px`,
width: `${agentPickerPosition.width}px`, width: `${agentPickerPosition.width}px`,
maxHeight: agentPickerPosition.maxHeight ? `${agentPickerPosition.maxHeight}px` : undefined, maxHeight: agentPickerPosition.maxHeight ? `${agentPickerPosition.maxHeight}px` : undefined,
@@ -2293,7 +2309,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
style={{ style={{
position: "fixed", 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`, left: `${priorityPickerPosition.left}px`,
width: `${priorityPickerPosition.width}px`, width: `${priorityPickerPosition.width}px`,
maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined, maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined,
@@ -2369,7 +2386,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
data-testid="model-nested-menu" data-testid="model-nested-menu"
style={{ style={{
position: "fixed", 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`, left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`, width: `${modelMenuPosition.width}px`,
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined, maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,

View File

@@ -572,10 +572,41 @@ describe("CustomModelDropdown", () => {
const portal = await screen.findByTestId("model-combobox-portal"); const portal = await screen.findByTestId("model-combobox-portal");
expect(portal.style.left).toBe("239px"); expect(portal.style.left).toBe("239px");
expect(portal.style.width).toBe("120px"); 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"); 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(<CustomModelDropdown label="Empty Model" value="" onChange={vi.fn()} models={[]} />);
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", () => { describe("Readable menu sizing", () => {
const setupBoundingRectMock = (rectValues: DOMRect) => { const setupBoundingRectMock = (rectValues: DOMRect) => {
const originalGetBCR = Element.prototype.getBoundingClientRect; const originalGetBCR = Element.prototype.getBoundingClientRect;
@@ -1070,10 +1101,9 @@ describe("CustomModelDropdown", () => {
await user.click(screen.getByRole("button", { name: "Executor Model" })); await user.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal"); const portal = await screen.findByTestId("model-combobox-portal");
const top = parseFloat(portal.style.top); // Upward placement must be independent of the max-height scroll cap.
expect(portal.style.top).toBe("auto");
// Should position upward: rect.top - estimatedHeight - 4 = 710 - 320 - 4 = 386 expect(portal.style.bottom).toBe("94px");
expect(top).toBe(386);
} finally { } finally {
restore(); restore();
Object.defineProperty(window, "innerHeight", { Object.defineProperty(window, "innerHeight", {

View File

@@ -3126,13 +3126,11 @@ describe("QuickEntryBox", () => {
expect(dropdown.classList.contains("dep-dropdown--portal")).toBe(true); expect(dropdown.classList.contains("dep-dropdown--portal")).toBe(true);
expect(dropdown.style.position).toBe("fixed"); expect(dropdown.style.position).toBe("fixed");
const top = parseFloat(dropdown.style.top);
const maxHeight = parseFloat(dropdown.style.maxHeight); const maxHeight = parseFloat(dropdown.style.maxHeight);
const gap = 4; const gap = 4;
// Menu bottom edge must sit `gap` above the trigger top (open upward, attached). // Bottom anchoring stays attached even when the menu's natural list is shorter than maxHeight.
expect(top + maxHeight).toBeCloseTo(150 - gap, 5); expect(dropdown.style.top).toBe("auto");
// Must not float at the old detached clamp (top ≈ 16 with maxHeight 200 leaving a large gap). expect(dropdown.style.bottom).toBe(`${viewportHeight - 150 + gap}px`);
expect(top + maxHeight + gap).toBeCloseTo(150, 5);
expect(maxHeight).toBeLessThanOrEqual(150 - 16 - gap); expect(maxHeight).toBeLessThanOrEqual(150 - 16 - gap);
}); });
@@ -4466,6 +4464,44 @@ describe("QuickEntryBox", () => {
expect(menu.style.width).toBeTruthy(); 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", () => { it("does not close model menu when clicking inside CustomModelDropdown portal", () => {
renderQuickEntryBox({}); renderQuickEntryBox({});
expandQuickEntry(); expandQuickEntry();

View File

@@ -29,6 +29,7 @@ describe("computeFixedMenuPosition", () => {
expect(position.openUpward).toBe(false); expect(position.openUpward).toBe(false);
expect(position.top).toBe(baseTrigger.bottom + 4); expect(position.top).toBe(baseTrigger.bottom + 4);
expect(position.bottom).toBeNull();
expect(position.left).toBe(baseTrigger.left); expect(position.left).toBe(baseTrigger.left);
expect(position.width).toBe(280); expect(position.width).toBe(280);
expect(position.maxHeight).toBe(320); expect(position.maxHeight).toBe(320);
@@ -47,8 +48,8 @@ describe("computeFixedMenuPosition", () => {
}); });
expect(position.openUpward).toBe(true); expect(position.openUpward).toBe(true);
// Menu bottom edge is gap above the trigger; top = trigger.top - gap - maxHeight. expect(position.top).toBeNull();
expect(position.top + position.maxHeight).toBe(trigger.top - 4); expect(position.bottom).toBe(600 - trigger.top + 4);
expect(position.maxHeight).toBeLessThanOrEqual(320); expect(position.maxHeight).toBeLessThanOrEqual(320);
expect(position.maxHeight).toBeGreaterThan(0); 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. // Old math floored maxHeight to 200 and clamped top to verticalPadding (16), detaching the menu.
expect(position.openUpward).toBe(true); expect(position.openUpward).toBe(true);
expect(position.maxHeight).toBe(130); expect(position.maxHeight).toBe(130);
expect(position.top).toBe(trigger.top - gap - position.maxHeight); expect(position.top).toBeNull();
expect(position.top).toBe(16); // verticalPadding // The CSS bottom offset is height-independent, even when maxHeight shrinks.
// Still attached: menu bottom + gap === trigger top expect(position.bottom).toBe(viewportHeight - trigger.top + gap);
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", () => { it("shrinks maxHeight when opening downward into a short viewport instead of lifting top off the trigger", () => {

View File

@@ -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). (same failure class as the TaskDetail Activity menu fix).
FNXC:QuickAddDepsMenu 2026-07-25-12:00: FNXC:QuickAddDepsMenu 2026-07-25-12:00:
Anchor-first: place the menu immediately below (or above) the trigger, then shrink `maxHeight` to the Anchor-first: place the menu immediately below the trigger or anchor its bottom immediately above it,
real free space. Never clamp `top` away from the trigger to preserve a preferred/min height floor — then shrink `maxHeight` to the real free space. Computing upward `top` from a preferred/clamped
that produced the Deps menu floating too high and unattached when space was tight. height detached short menus; never clamp placement to preserve a preferred/min height floor.
*/ */
export interface FixedMenuTriggerRect { export interface FixedMenuTriggerRect {
@@ -33,7 +33,8 @@ export interface FixedMenuPositionInput {
} }
export interface FixedMenuPosition { export interface FixedMenuPosition {
top: number; top: number | null;
bottom: number | null;
left: number; left: number;
width: number; width: number;
maxHeight: number; maxHeight: number;
@@ -88,13 +89,18 @@ export function computeFixedMenuPosition(input: FixedMenuPositionInput): FixedMe
if (openUpward) { if (openUpward) {
const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceAbove)); 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; FNXC:QuickAddMenuAnchor 2026-08-01-07:11:
return { top, left, width, maxHeight, openUpward: true }; 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)); const maxHeight = Math.max(0, Math.min(input.preferredHeight, spaceBelow));
// Top edge sits `gap` below the trigger bottom — always attached. // Top edge sits `gap` below the trigger bottom — always attached.
const top = input.triggerRect.bottom + gap; const top = input.triggerRect.bottom + gap;
return { top, left, width, maxHeight, openUpward: false }; return { top, bottom: null, left, width, maxHeight, openUpward: false };
} }