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 {
|
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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user