mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
fix(site): prevent slash menu from flashing at top-left corner when closed (#27326)
Typing `/` in the agent chat input opens the skills slash menu. Pressing Backspace to erase the `/` made the menu flash at the top-left corner of the app for a split second before disappearing. Radix keeps the popover content mounted through its exit animation, but the caret-positioned `PopoverAnchor` was unmounted in the same render that closed the menu. Without an anchor, the popper repositioned the still-visible closing content to the viewport origin. The anchor now stays rendered at the last known caret rect, so the menu holds its position while it animates out. Verified with a frame-by-frame position recorder in the browser: before the fix the closing menu jumped from the caret position to `(0, 4)`; after the fix it stays in place until unmount. Added a `BackspaceClosesMenuWithoutRepositioning` story that fails without the fix. > This PR was created by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
@@ -335,6 +335,33 @@ export const OutsideClickDismissesTriggerOnRefocus: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const BackspaceClosesMenuWithoutRepositioning: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await typeInEditor(canvasElement, "/");
|
||||
await findVisibleText("/reviewer");
|
||||
const popperWrapper = () =>
|
||||
document.querySelector<HTMLElement>(
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
);
|
||||
const openRect = popperWrapper()?.getBoundingClientRect();
|
||||
expect(openRect?.left).toBeGreaterThan(0);
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
// The closing menu must hold its position through the exit
|
||||
// animation instead of flashing at the viewport origin.
|
||||
let wrapper = popperWrapper();
|
||||
for (let frame = 0; wrapper && frame < 300; frame++) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
expect({ top: rect.top, left: rect.left }).toEqual({
|
||||
top: openRect?.top,
|
||||
left: openRect?.left,
|
||||
});
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
wrapper = popperWrapper();
|
||||
}
|
||||
expect(wrapper).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
// Stories below verify that on mobile viewports, the skills popup
|
||||
// sits directly above the chat input rather than being clipped
|
||||
// above the visible viewport.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -143,6 +144,14 @@ export const SkillsTriggerMenu = ({
|
||||
: undefined,
|
||||
].filter((item) => item !== undefined);
|
||||
const shouldRender = open && anchorRect;
|
||||
// Radix keeps closing content mounted through its exit animation.
|
||||
// Unmounting the anchor then would reposition the closing menu to
|
||||
// the viewport origin, so keep it at the last known caret rect.
|
||||
const [lastAnchorRect, setLastAnchorRect] = useState(anchorRect);
|
||||
if (anchorRect && anchorRect !== lastAnchorRect) {
|
||||
setLastAnchorRect(anchorRect);
|
||||
}
|
||||
const renderedAnchorRect = anchorRect ?? lastAnchorRect;
|
||||
const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0;
|
||||
const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : "";
|
||||
|
||||
@@ -176,16 +185,16 @@ export const SkillsTriggerMenu = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldRender && (
|
||||
{renderedAnchorRect && (
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
top: renderedAnchorRect.top,
|
||||
left: renderedAnchorRect.left,
|
||||
width: 1,
|
||||
height: Math.max(anchorRect.height, MIN_ANCHOR_HEIGHT_PX),
|
||||
height: Math.max(renderedAnchorRect.height, MIN_ANCHOR_HEIGHT_PX),
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user