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:
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
136
packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts
Normal file
136
packages/dashboard/app/utils/__tests__/fixedMenuPosition.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
100
packages/dashboard/app/utils/fixedMenuPosition.ts
Normal file
100
packages/dashboard/app/utils/fixedMenuPosition.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user