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", "") + }) +})