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.
This commit is contained in:
gsxdsm
2026-07-25 21:01:25 -07:00
parent c8427f1c1e
commit 10ebd0e025
4 changed files with 388 additions and 265 deletions

View File

@@ -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(() => {

View File

@@ -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",

View File

@@ -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 });
});
});

View File

@@ -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<Document, "documentElement"> | null | undefined = typeof document !== "undefined" ? document : null,
win: Pick<Window, "innerWidth" | "innerHeight"> | 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 };
}