Skip to content
Draft
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
69 changes: 51 additions & 18 deletions src/elements/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export class LexicalPromptElement extends HTMLElement {
#globalListeners = new ListenerBin()
#popoverListeners = new ListenerBin()
#debouncedFilterOptions = debounce(() => this.#filterOptions(), FILTER_DEBOUNCE_INTERVAL)
#repositionFrame = null
#anchor = null

constructor() {
super()
Expand Down Expand Up @@ -216,9 +218,14 @@ export class LexicalPromptElement extends HTMLElement {

this.#popoverListeners.track(
registerEventListener(this.#editorElement, "keydown", this.#handleKeydownOnPopover),
registerEventListener(this.#editorElement, "lexxy:change", this.#debouncedFilterOptions)
registerEventListener(this.#editorElement, "lexxy:change", this.#debouncedFilterOptions),
registerEventListener(window, "scroll", this.#repositionPopover, { capture: true, passive: true })
)

if (window.visualViewport) {
this.#popoverListeners.track(registerEventListener(window.visualViewport, "resize", this.#repositionPopover))
}

this.#registerKeyListeners()
this.#addCursorPositionListener()
}
Expand Down Expand Up @@ -295,25 +302,24 @@ export class LexicalPromptElement extends HTMLElement {
}
}

// Right after a Turbo history restore the editor reconnects before the DOM selection
// is re-established, so the cursor geometry is momentarily unavailable. Anchoring then
// would pin the menu to the editor's left edge for the rest of the open cycle, so we
// skip it and let a later reposition anchor it once the selection is ready. The menu
// stays hidden until anchored (see the `[data-anchored]` rule in the stylesheet).
// The menu anchors once at the trigger and stays pinned there while the search term
// is typed. Cursor geometry is momentarily unavailable right after a Turbo history
// restore (the editor reconnects before the DOM selection is re-established), so we
// bail until it is reliable rather than anchoring to a stale position; the menu stays
// hidden until then (see the `--visible[data-anchored]` rule in the stylesheet).
// The vertical flip is re-evaluated on every reposition (scroll, viewport resize) so
// the menu keeps clear of a clipping container as the editor moves.
#positionPopover() {
const cursorPosition = this.#selection.cursorPosition
if (!cursorPosition) return
this.#anchor ??= this.#selection.cursorPosition
if (!this.#anchor) return

const { x, y, fontSize } = cursorPosition
const { x, y, fontSize } = this.#anchor
const editorRect = this.#editorElement.getBoundingClientRect()
const contentRect = this.#editorContentElement.getBoundingClientRect()
const verticalOffset = contentRect.top - editorRect.top

if (!this.popoverElement.hasAttribute("data-anchored")) {
this.#setPopoverOffsetX(x)
this.#setPopoverOffsetY(y + verticalOffset)
this.popoverElement.toggleAttribute("data-anchored", true)
}
this.#setPopoverOffsetX(x)
this.popoverElement.toggleAttribute("data-anchored", true)

const popoverRect = this.popoverElement.getBoundingClientRect()

Expand All @@ -323,12 +329,38 @@ export class LexicalPromptElement extends HTMLElement {

const forceTop = this.verticalDirection === "top"
const forceBottom = this.verticalDirection === "bottom"
const overflowsWindow = popoverRect.bottom > window.innerHeight
const downwardBottom = editorRect.top + verticalOffset + y + popoverRect.height
const clipAtBottom = !forceBottom && (forceTop || downwardBottom > this.#availableBottom())

this.#setPopoverOffsetY(clipAtBottom ? contentRect.height - y + fontSize : y + verticalOffset)
this.popoverElement.toggleAttribute("data-clipped-at-bottom", clipAtBottom)
}

// The bottom edge the menu must stay within: the lowest visible bottom among the
// editor's clipping ancestors (overflow other than `visible`), capped by the window.
// This flips the menu above the cursor when a scroll container or modal would clip
// it, even while the window itself still has room below.
#availableBottom() {
let bottom = window.innerHeight
let node = this.#editorElement

if (!forceBottom && (forceTop || overflowsWindow)) {
this.#setPopoverOffsetY(contentRect.height - y + fontSize)
this.popoverElement.toggleAttribute("data-clipped-at-bottom", true)
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
}

#repositionPopover = () => {
if (this.#repositionFrame || this.closed) return

this.#repositionFrame = requestAnimationFrame(() => {
this.#repositionFrame = null
if (this.open) this.#positionPopover()
})
}

#setPopoverOffsetX(value) {
Expand All @@ -340,6 +372,7 @@ export class LexicalPromptElement extends HTMLElement {
}

#resetPopoverPosition() {
this.#anchor = null
this.popoverElement.removeAttribute("data-clipped-at-bottom")
this.popoverElement.removeAttribute("data-clipped-at-right")
this.popoverElement.removeAttribute("data-anchored")
Expand Down
81 changes: 81 additions & 0 deletions test/browser/fixtures/prompt-overflow-container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!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>
<template type="editor">
<span class="person person--inline">Alice</span>
</template>
</lexxy-prompt-item>
<lexxy-prompt-item search="Zacharias" sgid="test-sgid-zacharias">
<template type="menu">
<span class="person person--prompt-item">Zacharias</span>
</template>
<template type="editor">
<span class="person person--inline">Zacharias</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>
<template type="editor">
<span class="person person--inline">Sam</span>
</template>
</lexxy-prompt-item>
<lexxy-prompt-item search="Taylor" sgid="test-sgid-taylor">
<template type="menu">
<span class="person person--prompt-item">Taylor</span>
</template>
<template type="editor">
<span class="person person--inline">Taylor</span>
</template>
</lexxy-prompt-item>
</lexxy-prompt>
</lexxy-editor>
</div>
</form>

<script type="module" src="/editor.js"></script>
</body>
</html>
54 changes: 54 additions & 0 deletions test/browser/tests/prompts/overflow_container.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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 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("drops the upward flip once the container has room below the cursor again", 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", "")

await page.evaluate(() => {
document.querySelector(".scroll-container").style.blockSize = "600px"
})

await editor.send("a")

await expect(popover).not.toHaveAttribute("data-clipped-at-bottom", "")
})
})