Skip to content

Commit 9617631

Browse files
committed
fix: no more scrolling to top when opening ai menu
1 parent f728f6a commit 9617631

8 files changed

Lines changed: 189 additions & 67 deletions

File tree

packages/react/src/components/Popovers/FloatingUIOptions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
FloatingFocusManagerProps,
23
UseDismissProps,
34
UseFloatingOptions,
45
UseHoverProps,
@@ -14,4 +15,10 @@ export type FloatingUIOptions = {
1415
useDismissProps?: UseDismissProps;
1516
useHoverProps?: UseHoverProps;
1617
elementProps?: HTMLAttributes<HTMLDivElement>;
18+
/**
19+
* Props to pass to the `FloatingFocusManager` component.
20+
*
21+
* If omitted, no `FloatingFocusManager` will be used.
22+
*/
23+
focusManagerProps?: Omit<FloatingFocusManagerProps, "context" | "children">;
1724
};

packages/react/src/components/Popovers/GenericPopover.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
2-
useFloating,
3-
useTransitionStyles,
2+
autoUpdate,
3+
FloatingFocusManager,
44
useDismiss,
5+
useFloating,
6+
useHover,
57
useInteractions,
68
useMergeRefs,
79
useTransitionStatus,
8-
autoUpdate,
9-
useHover,
10+
useTransitionStyles,
1011
} from "@floating-ui/react";
1112
import { HTMLAttributes, ReactNode, useEffect, useRef } from "react";
1213

@@ -175,6 +176,16 @@ export const GenericPopover = (
175176
);
176177
}
177178

179+
if (props.focusManagerProps) {
180+
return (
181+
<FloatingFocusManager {...props.focusManagerProps} context={context}>
182+
<div ref={mergedRefs} {...mergedProps}>
183+
{props.children}
184+
</div>
185+
</FloatingFocusManager>
186+
);
187+
}
188+
178189
return (
179190
<div ref={mergedRefs} {...mergedProps}>
180191
{props.children}

packages/react/src/editor/ComponentsContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ComponentType,
44
createContext,
55
CSSProperties,
6+
ForwardedRef,
67
HTMLInputAutoCompleteAttribute,
78
KeyboardEvent,
89
MouseEvent,
@@ -274,6 +275,7 @@ export type ComponentProps = {
274275
onSubmit?: () => void;
275276
autoComplete?: HTMLInputAutoCompleteAttribute;
276277
"aria-activedescendant"?: string;
278+
ref?: ForwardedRef<HTMLInputElement>;
277279
};
278280
};
279281
Menu: {

packages/xl-ai/src/components/AIMenu/AIMenuController.tsx

Lines changed: 63 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -26,71 +26,75 @@ export const AIMenuController = (props: {
2626
const blockId = aiMenuState === "closed" ? undefined : aiMenuState.blockId;
2727

2828
const floatingUIOptions = useMemo<FloatingUIOptions>(
29-
() => ({
30-
...props.floatingUIOptions,
31-
useFloatingOptions: {
32-
open: aiMenuState !== "closed",
33-
placement: "bottom",
34-
middleware: [
35-
offset(10),
36-
flip(),
37-
size({
38-
apply({ rects, elements }) {
39-
Object.assign(elements.floating.style, {
40-
width: `${rects.reference.width}px`,
41-
});
42-
},
43-
}),
44-
],
45-
onOpenChange: (open) => {
46-
if (open || aiMenuState === "closed") {
47-
return;
48-
}
29+
() =>
30+
({
31+
...props.floatingUIOptions,
32+
useFloatingOptions: {
33+
open: aiMenuState !== "closed",
34+
placement: "bottom",
35+
middleware: [
36+
offset(10),
37+
flip(),
38+
size({
39+
apply({ rects, elements }) {
40+
Object.assign(elements.floating.style, {
41+
width: `${rects.reference.width}px`,
42+
});
43+
},
44+
}),
45+
],
46+
onOpenChange: (open) => {
47+
if (open || aiMenuState === "closed") {
48+
return;
49+
}
4950

50-
if (aiMenuState.status === "user-input") {
51-
ai.closeAIMenu();
52-
} else if (
53-
aiMenuState.status === "user-reviewing" ||
54-
aiMenuState.status === "error"
55-
) {
56-
ai.rejectChanges();
57-
}
58-
},
59-
whileElementsMounted(reference, floating, update) {
60-
return autoUpdate(reference, floating, update, {
61-
animationFrame: true,
62-
});
63-
},
64-
...props.floatingUIOptions?.useFloatingOptions,
65-
},
66-
useDismissProps: {
67-
enabled:
68-
aiMenuState === "closed" || aiMenuState.status === "user-input",
69-
// We should just be able to set `referencePress: true` instead of
70-
// using this listener, but this doesn't seem to trigger.
71-
// (probably because we don't assign the referenceProps to the reference element)
72-
outsidePress: (event) => {
73-
if (event.target instanceof Element) {
74-
const blockElement = event.target.closest(".bn-block");
75-
if (
76-
blockElement &&
77-
blockElement.getAttribute("data-id") === blockId
78-
) {
51+
if (aiMenuState.status === "user-input") {
7952
ai.closeAIMenu();
53+
} else if (
54+
aiMenuState.status === "user-reviewing" ||
55+
aiMenuState.status === "error"
56+
) {
57+
ai.rejectChanges();
58+
}
59+
},
60+
whileElementsMounted(reference, floating, update) {
61+
return autoUpdate(reference, floating, update, {
62+
animationFrame: true,
63+
});
64+
},
65+
...props.floatingUIOptions?.useFloatingOptions,
66+
},
67+
useDismissProps: {
68+
enabled:
69+
aiMenuState === "closed" || aiMenuState.status === "user-input",
70+
// We should just be able to set `referencePress: true` instead of
71+
// using this listener, but this doesn't seem to trigger.
72+
// (probably because we don't assign the referenceProps to the reference element)
73+
outsidePress: (event) => {
74+
if (event.target instanceof Element) {
75+
const blockElement = event.target.closest(".bn-block");
76+
if (
77+
blockElement &&
78+
blockElement.getAttribute("data-id") === blockId
79+
) {
80+
ai.closeAIMenu();
81+
}
8082
}
81-
}
8283

83-
return true;
84+
return true;
85+
},
86+
...props.floatingUIOptions?.useDismissProps,
8487
},
85-
...props.floatingUIOptions?.useDismissProps,
86-
},
87-
elementProps: {
88-
style: {
89-
zIndex: 100,
88+
elementProps: {
89+
style: {
90+
zIndex: 100,
91+
},
92+
...props.floatingUIOptions?.elementProps,
9093
},
91-
...props.floatingUIOptions?.elementProps,
92-
},
93-
}),
94+
// we use the focus manager instead of `autoFocus={true}` to prevent "page-scrolls-to-top-when-opening-the-floating-element"
95+
// see https://floating-ui.com/docs/floatingfocusmanager#page-scrolls-to-top-when-opening-the-floating-element
96+
focusManagerProps: {},
97+
}) satisfies FloatingUIOptions,
9498
[ai, aiMenuState, blockId, props.floatingUIOptions],
9599
);
96100

packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
useCallback,
1313
useEffect,
1414
useMemo,
15+
useRef,
1516
useState,
1617
} from "react";
1718

@@ -30,7 +31,8 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
3031
// const dict = useAIDictionary();
3132
const Components = useComponentsContext()!;
3233

33-
const { onManualPromptSubmit, promptText, onPromptTextChange } = props;
34+
const { onManualPromptSubmit, promptText, onPromptTextChange, disabled } =
35+
props;
3436

3537
// Only used internal state when `props.prompText` is undefined (i.e., uncontrolled mode)
3638
const [internalPromptText, setInternalPromptText] = useState<string>("");
@@ -95,18 +97,31 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
9597
setSelectedIndex(0);
9698
}, [promptTextToUse, setSelectedIndex]);
9799

100+
const inputRef = useRef<HTMLInputElement>(null);
101+
const hasBeenDisabled = useRef(disabled);
102+
103+
useEffect(() => {
104+
// This effect is used so that after the input has been disabled (for example, when AI results are loaded),
105+
// the input is focused again.
106+
if (inputRef.current && hasBeenDisabled.current && !disabled) {
107+
inputRef.current.focus();
108+
}
109+
110+
if (disabled) {
111+
hasBeenDisabled.current = true;
112+
}
113+
}, [disabled]);
114+
98115
return (
99116
<div className={"bn-combobox"}>
100117
<Components.Generic.Form.Root>
101118
<Components.Generic.Form.TextInput
102-
// Change the key when disabled change, so that autofocus is retriggered
103-
key={"input-" + props.disabled}
119+
ref={inputRef}
104120
className={"bn-combobox-input"}
105121
name={"ai-prompt"}
106122
variant={"large"}
107123
icon={props.icon}
108124
value={promptTextToUse || ""}
109-
autoFocus={true}
110125
placeholder={props.placeholder}
111126
disabled={props.disabled}
112127
onKeyDown={handleKeyDown}

tests/src/end-to-end/ai/ai.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { expect } from "@playwright/test";
2+
import { test } from "../../setup/setupScript.js";
3+
import { AI_URL, EDITOR_SELECTOR } from "../../utils/const.js";
4+
5+
// Use a small viewport so the editor content requires scrolling.
6+
test.use({ viewport: { width: 800, height: 400 } });
7+
8+
test.beforeEach(async ({ page }) => {
9+
await page.goto(AI_URL);
10+
});
11+
12+
test.describe("AI Menu Scroll Regression", () => {
13+
test("opening the AI menu should not scroll the page to the top", async ({
14+
page,
15+
}) => {
16+
// Wait for the editor to be ready
17+
await page.waitForSelector(EDITOR_SELECTOR);
18+
19+
// Click on the last paragraph so the cursor is near the bottom of the content
20+
const lastParagraph = page
21+
.locator("[data-content-type='paragraph']")
22+
.last();
23+
await lastParagraph.click();
24+
25+
// Ensure the page is scrolled down (editor puts cursor near bottom of content)
26+
// We scroll down explicitly to make sure we're not at the top
27+
await page.evaluate(() => {
28+
window.scrollTo(0, document.body.scrollHeight);
29+
});
30+
await page.waitForTimeout(200);
31+
32+
// Record the scroll position before opening the AI menu
33+
const scrollYBefore = await page.evaluate(() => window.scrollY);
34+
35+
// Sanity check: we should actually be scrolled down
36+
expect(scrollYBefore).toBeGreaterThan(0);
37+
38+
// Open the AI menu via the slash command
39+
// First, focus back on the editor at the last paragraph
40+
await lastParagraph.click();
41+
await page.waitForTimeout(100);
42+
43+
// Type /ai to open the slash menu and select the AI option
44+
await page.keyboard.type("/ai", { delay: 50 });
45+
await page.waitForTimeout(300);
46+
47+
// Wait for the suggestion menu to appear
48+
const suggestionMenu = page.locator(".bn-suggestion-menu");
49+
await suggestionMenu.waitFor({ state: "visible", timeout: 3000 });
50+
51+
// Click the AI suggestion menu item to open the AI menu
52+
const aiMenuItem = suggestionMenu
53+
.locator(".bn-suggestion-menu-item")
54+
.first();
55+
await aiMenuItem.click();
56+
57+
// Wait for the AI menu (combobox input) to appear
58+
const aiMenuInput = page.locator(
59+
".bn-combobox-input input, .bn-combobox input",
60+
);
61+
await aiMenuInput.waitFor({ state: "visible", timeout: 3000 });
62+
63+
// Brief wait for any scroll side effects to take place
64+
await page.waitForTimeout(300);
65+
66+
// Screenshot after opening AI menu
67+
expect(await page.screenshot()).toMatchSnapshot(
68+
"ai_menu_scroll_position.png",
69+
);
70+
71+
// Check that the scroll position has not jumped to the top
72+
const scrollYAfter = await page.evaluate(() => window.scrollY);
73+
expect(scrollYAfter).toBeGreaterThan(0);
74+
expect(scrollYAfter).toBeGreaterThanOrEqual(scrollYBefore * 0.2);
75+
76+
// Verify the AI menu input is actually focused
77+
await expect(aiMenuInput).toBeFocused();
78+
});
79+
});
77.8 KB
Loading

tests/src/utils/const.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export const ARIAKIT_URL = !process.env.RUN_IN_DOCKER
1111
? `http://localhost:${PORT}/basic/ariakit?hideMenu`
1212
: `http://host.docker.internal:${PORT}/basic/ariakit?hideMenu`;
1313

14+
export const AI_URL = !process.env.RUN_IN_DOCKER
15+
? `http://localhost:${PORT}/ai/minimal?hideMenu`
16+
: `http://host.docker.internal:${PORT}/ai/minimal?hideMenu`;
17+
1418
export const STATIC_URL = !process.env.RUN_IN_DOCKER
1519
? `http://localhost:${PORT}/backend/rendering-static-documents?hideMenu`
1620
: `http://host.docker.internal:${PORT}/backend/rendering-static-documents?hideMenu`;

0 commit comments

Comments
 (0)