fix: anchor task Activity view dropdown under its tab
The Activity view menu (Live/Feed/Raw) is position:fixed and portaled to <body>, so it is anchored to the layout viewport. It was clamped using window.visualViewport width/offset, which under pinch-zoom or an open mobile keyboard diverges from the layout viewport and shoved the popup to the left of the modal. Position it purely from the layout viewport (document.documentElement.clientWidth/clientHeight) with no visual-viewport offset so it stays under the "Activity" trigger. Adds a regression test asserting the menu anchors under the trigger even when the visual viewport diverges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fix-activity-dropdown-alignment.md
Normal file
7
.changeset/fix-activity-dropdown-alignment.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Align the task Activity view dropdown under its tab instead of drifting to the left of the modal.
|
||||
category: fix
|
||||
dev: TaskDetailModal's position:fixed Activity menu now clamps to the layout viewport (document.documentElement.clientWidth/clientHeight) and no longer mixes in window.visualViewport width/offset, which shoved the popup off-anchor under pinch-zoom or an open mobile keyboard.
|
||||
@@ -2845,41 +2845,26 @@ export function TaskDetailContent({
|
||||
activityViewButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const visualViewport = window.visualViewport;
|
||||
if (visualViewport && visualViewport.width > 0 && visualViewport.height > 0) {
|
||||
return {
|
||||
width: visualViewport.width,
|
||||
height: visualViewport.height,
|
||||
offsetTop: visualViewport.offsetTop,
|
||||
offsetLeft: visualViewport.offsetLeft,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailActivity 2026-07-01-12:20:
|
||||
The Activity view menu is `position: fixed` and portaled to <body>, so it is anchored to the LAYOUT viewport, and `getBoundingClientRect()` returns layout-viewport-relative coordinates that a fixed element consumes directly.
|
||||
Position it purely from the layout viewport (`document.documentElement.clientWidth/clientHeight`) and never mix in `window.visualViewport` width/height/offset: under pinch-zoom or an open mobile keyboard the visual viewport diverges from the layout viewport (smaller width, nonzero offsetLeft/Top), and combining a shrunken visual-viewport width with a layout-viewport `getBoundingClientRect()` clamped `left` far off the trigger, so the popup rendered detached to the left of the modal instead of under the "Activity" tab.
|
||||
*/
|
||||
const updateActivityViewMenuPosition = useCallback(() => {
|
||||
const trigger = activityViewButtonRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const docEl = document.documentElement;
|
||||
const viewportWidth = docEl?.clientWidth || window.innerWidth;
|
||||
const viewportHeight = docEl?.clientHeight || window.innerHeight;
|
||||
const horizontalPadding = ACTIVITY_VIEW_MENU_VIEWPORT_PADDING;
|
||||
const verticalPadding = ACTIVITY_VIEW_MENU_VIEWPORT_PADDING;
|
||||
const gap = ACTIVITY_VIEW_MENU_TRIGGER_GAP;
|
||||
const preferredWidth = Math.max(rect.width, ACTIVITY_VIEW_MENU_MIN_WIDTH);
|
||||
const width = Math.min(preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, ACTIVITY_VIEW_MENU_MIN_WIDTH));
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, ACTIVITY_VIEW_MENU_MIN_HEIGHT);
|
||||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, ACTIVITY_VIEW_MENU_MIN_HEIGHT);
|
||||
const openUpward = spaceBelow < ACTIVITY_VIEW_MENU_MIN_HEIGHT && spaceAbove > spaceBelow;
|
||||
@@ -2887,19 +2872,17 @@ export function TaskDetailContent({
|
||||
Math.min(openUpward ? availableAbove : availableBelow, ACTIVITY_VIEW_MENU_MAX_HEIGHT),
|
||||
ACTIVITY_VIEW_MENU_MIN_HEIGHT,
|
||||
);
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
// Anchor the menu's left edge under the trigger, shifting left only enough to stay on-screen, and never past the left padding.
|
||||
const left = Math.max(
|
||||
horizontalPadding,
|
||||
Math.min(rect.left, viewportWidth - horizontalPadding - width),
|
||||
);
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||||
);
|
||||
? Math.max(verticalPadding, rect.top - maxHeight - gap)
|
||||
: Math.min(rect.bottom + gap, viewportHeight - verticalPadding - maxHeight);
|
||||
|
||||
setActivityViewMenuPosition({ top, left, minWidth: width, maxHeight });
|
||||
}, [getEffectiveViewport]);
|
||||
}, []);
|
||||
|
||||
const activityViewOptions = useMemo<Array<{ value: ActivitySegment; label: string }>>(() => [
|
||||
{ value: "current", label: t("taskDetail.activity.current", "Live") },
|
||||
|
||||
@@ -198,6 +198,57 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// FN-7375 regression: the position:fixed Activity menu is anchored to the layout viewport, so a
|
||||
// diverging window.visualViewport (pinch-zoom / open mobile keyboard: smaller width, nonzero
|
||||
// offsetLeft) must NOT shift the menu away from the trigger. Symptom was the popup rendering
|
||||
// detached to the far left of the modal instead of under the "Activity" tab.
|
||||
it("anchors the Activity menu under the trigger regardless of a diverging visual viewport", () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
|
||||
const documentElement = document.documentElement;
|
||||
|
||||
// Layout viewport is 1280x800 (wide desktop); the Activity trigger sits well inside it.
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 });
|
||||
Object.defineProperty(documentElement, "clientWidth", { configurable: true, value: 1280 });
|
||||
Object.defineProperty(documentElement, "clientHeight", { configurable: true, value: 800 });
|
||||
// A pinch-zoomed visual viewport: much smaller and panned. The old code fed this into a fixed
|
||||
// element's clamp and shoved it to the left edge.
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: { width: 320, height: 480, offsetLeft: 240, offsetTop: 120, addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (this.classList.contains("detail-tab--activity")) {
|
||||
return { x: 700, y: 96, top: 96, right: 792, bottom: 132, left: 700, width: 92, height: 36, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
};
|
||||
|
||||
try {
|
||||
renderModal();
|
||||
const menu = openActivityViewMenu();
|
||||
// Left edge anchored under the trigger (rect.left = 700), NOT clamped to a shrunken visual
|
||||
// viewport and NOT displaced by visualViewport.offsetLeft.
|
||||
expect(menu.style.left).toBe("700px");
|
||||
// Opens below the trigger (rect.bottom = 132) + gap, unaffected by visualViewport.offsetTop.
|
||||
expect(menu.style.top).toBe("136px");
|
||||
} finally {
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
|
||||
if (originalVisualViewport) {
|
||||
Object.defineProperty(window, "visualViewport", originalVisualViewport);
|
||||
} else {
|
||||
delete (window as unknown as { visualViewport?: unknown }).visualViewport;
|
||||
}
|
||||
delete (documentElement as unknown as { clientWidth?: number }).clientWidth;
|
||||
delete (documentElement as unknown as { clientHeight?: number }).clientHeight;
|
||||
}
|
||||
});
|
||||
|
||||
it("restores Chat-first ordering and omitted non-done default when the project setting is enabled", () => {
|
||||
mockRawLogs([]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user