Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/elements/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
Expand Down
59 changes: 59 additions & 0 deletions test/browser/fixtures/prompt-overflow-container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test - Prompt overflow container</title>
<link rel="stylesheet" href="/styles.css">

<style>
body {
margin: 0;
}

.scroll-container {
block-size: 220px;
inline-size: 360px;
overflow: auto;
border: 1px solid #ccc;
}

.filler {
block-size: 150px;
}

lexxy-editor {
border: 1px solid #ccc;
display: block;
}

.lexxy-editor__content {
min-block-size: 64px;
padding: 8px;
}
</style>
</head>
<body>
<form>
<div class="scroll-container">
<div class="filler"></div>
<lexxy-editor class="lexxy-content" placeholder="Prompt in a scroll container">
<lexxy-prompt trigger="@" name="mention">
<lexxy-prompt-item search="Alice" sgid="test-sgid-alice">
<template type="menu">
<span class="person person--prompt-item">Alice</span>
</template>
</lexxy-prompt-item>
<lexxy-prompt-item search="Sam" sgid="test-sgid-sam">
<template type="menu">
<span class="person person--prompt-item">Sam</span>
</template>
</lexxy-prompt-item>
</lexxy-prompt>
</lexxy-editor>
</div>
</form>

<script type="module" src="/editor.js"></script>
</body>
</html>
52 changes: 52 additions & 0 deletions test/browser/tests/prompts/overflow_container.test.js
Original file line number Diff line number Diff line change
@@ -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", "")
})
})