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:
7
.changeset/quick-add-upward-menu-anchor.md
Normal file
7
.changeset/quick-add-upward-menu-anchor.md
Normal 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.
|
||||
@@ -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`,
|
||||
|
||||
@@ -221,14 +221,20 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
const nodePickerPortalRef = useRef<HTMLDivElement>(null);
|
||||
const priorityPickerRef = 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);
|
||||
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<HTMLButtonElement>(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>(() =>
|
||||
typeof document !== "undefined" ? document.body : null,
|
||||
);
|
||||
@@ -236,7 +242,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
const workflowPickerRef = useRef<HTMLDivElement>(null);
|
||||
const workflowTriggerRef = useRef<HTMLButtonElement>(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 [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,
|
||||
|
||||
@@ -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(<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", () => {
|
||||
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", {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user