diff --git a/src/elements/prompt.js b/src/elements/prompt.js
index e7b556b88..e8c8ece2f 100644
--- a/src/elements/prompt.js
+++ b/src/elements/prompt.js
@@ -323,14 +323,33 @@ export class LexicalPromptElement extends HTMLElement {
const forceTop = this.verticalDirection === "top"
const forceBottom = this.verticalDirection === "bottom"
- const overflowsWindow = popoverRect.bottom > window.innerHeight
+ const overflowsViewport = popoverRect.bottom > this.#availableBottom()
- if (!forceBottom && (forceTop || overflowsWindow)) {
+ if (!forceBottom && (forceTop || overflowsViewport)) {
this.#setPopoverOffsetY(contentRect.height - y + fontSize)
this.popoverElement.toggleAttribute("data-clipped-at-bottom", true)
}
}
+ // The bottom edge the menu must stay within: the tightest clipping bound among the
+ // editor and its ancestors — the smallest bottom of any element whose computed
+ // `overflow-y` is not `visible` — capped by the window. This flips the menu above the
+ // cursor when a scroll container or modal would clip it; with no clipping ancestor it
+ // returns `window.innerHeight`, leaving an unclipped editor anchored below as before.
+ #availableBottom() {
+ let bottom = window.innerHeight
+ let node = this.#editorElement
+
+ while (node && node !== document.body && node !== document.documentElement) {
+ if (getComputedStyle(node).overflowY !== "visible") {
+ bottom = Math.min(bottom, node.getBoundingClientRect().bottom)
+ }
+ node = node.parentElement
+ }
+
+ return bottom
+ }
+
#setPopoverOffsetX(value) {
this.popoverElement.style.setProperty("--lexxy-prompt-offset-x", `${value}px`)
}
diff --git a/test/browser/fixtures/prompt-overflow-container.html b/test/browser/fixtures/prompt-overflow-container.html
new file mode 100644
index 000000000..2d34d552b
--- /dev/null
+++ b/test/browser/fixtures/prompt-overflow-container.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Lexxy Test - Prompt overflow container
+
+
+
+
+
+
+
+
+
+
diff --git a/test/browser/tests/prompts/overflow_container.test.js b/test/browser/tests/prompts/overflow_container.test.js
new file mode 100644
index 000000000..d4084857b
--- /dev/null
+++ b/test/browser/tests/prompts/overflow_container.test.js
@@ -0,0 +1,52 @@
+import { test } from "../../test_helper.js"
+import { EditorHandle } from "../../helpers/editor_handle.js"
+import { expect } from "@playwright/test"
+
+test.describe("Prompt popover inside a clipping container", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 800, height: 700 })
+ await page.goto("/prompt-overflow-container.html")
+ })
+
+ test("flips above the cursor when the menu overflows a scroll container while the window still has room below", async ({ page }) => {
+ const editor = new EditorHandle(page, "lexxy-editor")
+ await editor.waitForConnected()
+
+ await editor.send("@")
+
+ const popover = page.locator(".lexxy-prompt-menu--visible")
+ await expect(popover).toBeVisible({ timeout: 5_000 })
+ await expect(popover).toHaveAttribute("data-clipped-at-bottom", "")
+
+ const positions = await page.evaluate(() => {
+ const container = document.querySelector(".scroll-container")
+ const popover = document.querySelector(".lexxy-prompt-menu--visible")
+
+ return {
+ containerBottom: container.getBoundingClientRect().bottom,
+ popoverBottom: popover.getBoundingClientRect().bottom,
+ viewportBottom: window.innerHeight,
+ }
+ })
+
+ expect(positions.popoverBottom).toBeLessThanOrEqual(positions.containerBottom + 1)
+ expect(positions.popoverBottom).toBeLessThan(positions.viewportBottom)
+ })
+
+ test("stays below the cursor when no ancestor clips and the window has room", async ({ page }) => {
+ await page.evaluate(() => {
+ const container = document.querySelector(".scroll-container")
+ container.style.overflow = "visible"
+ container.style.blockSize = "auto"
+ })
+
+ const editor = new EditorHandle(page, "lexxy-editor")
+ await editor.waitForConnected()
+
+ await editor.send("@")
+
+ const popover = page.locator(".lexxy-prompt-menu--visible")
+ await expect(popover).toBeVisible({ timeout: 5_000 })
+ await expect(popover).not.toHaveAttribute("data-clipped-at-bottom", "")
+ })
+})