feat(spa): rich-text input — restore $/@ autocomplete dropdown with keyboard nav

Teleport the suggestions popover to <body> and position with `position:
fixed` so it escapes the wrapper's `overflow: hidden` (which previously
clipped it). Adds outside-click / scroll / resize close handlers, a pi
icon per item, and tests for ArrowDown navigation + space-closes-popup.
This commit is contained in:
DumiFlex
2026-04-27 21:34:31 +03:00
parent abae630cc7
commit 60f911a0f8
2 changed files with 205 additions and 50 deletions
+65 -5
View File
@@ -84,6 +84,17 @@ describe("RichTextInput.vue", () => {
wrap.unmount();
});
// The autocomplete popover is teleported to <body>, so we query the
// document directly rather than via the wrapper's subtree.
function popoverItems(): string[] {
return Array.from(document.querySelectorAll(".wp-rt-suggestions__item")).map(
(el) => (el as HTMLElement).textContent ?? "",
);
}
function popoverExists(): boolean {
return document.querySelector(".wp-rt-suggestions") !== null;
}
it("opens autocomplete and filters by prefix when typing $", async () => {
const wrap = mount(RichTextInput, {
props: {
@@ -98,8 +109,7 @@ describe("RichTextInput.vue", () => {
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
const suggestions = wrap.findAll(".wp-rt-suggestions__item");
const labels = suggestions.map((b) => b.text());
const labels = popoverItems();
expect(labels.length).toBeGreaterThan(0);
expect(labels.some((l) => l.includes("person"))).toBe(true);
expect(labels.some((l) => l.includes("people"))).toBe(true);
@@ -122,7 +132,7 @@ describe("RichTextInput.vue", () => {
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
const labels = wrap.findAll(".wp-rt-suggestions__item").map((b) => b.text());
const labels = popoverItems();
expect(labels.some((l) => l.includes("beta"))).toBe(true);
expect(labels.some((l) => l.includes("alpha"))).toBe(false);
wrap.unmount();
@@ -162,10 +172,60 @@ describe("RichTextInput.vue", () => {
el.setSelectionRange(2, 2);
await input.trigger("input");
await tick();
expect(wrap.find(".wp-rt-suggestions").exists()).toBe(true);
expect(popoverExists()).toBe(true);
await input.trigger("keydown", { key: "Escape" });
await tick();
expect(wrap.find(".wp-rt-suggestions").exists()).toBe(false);
expect(popoverExists()).toBe(false);
wrap.unmount();
});
it("ArrowDown advances suggestion selection", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["person", "people"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$p";
el.setSelectionRange(2, 2);
await input.trigger("input");
await tick();
const before = document.querySelector<HTMLElement>(
".wp-rt-suggestions__item[data-active]",
);
expect(before?.textContent).toContain("person");
await input.trigger("keydown", { key: "ArrowDown" });
await tick();
const after = document.querySelector<HTMLElement>(
".wp-rt-suggestions__item[data-active]",
);
expect(after?.textContent).toContain("people");
wrap.unmount();
});
it("typing $ followed by space without picking closes the popup", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["person"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$p";
el.setSelectionRange(2, 2);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(true);
el.value = "$p ";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(false);
wrap.unmount();
});
+140 -45
View File
@@ -8,9 +8,13 @@
* mirrored `<div>` painted behind a transparent textarea — see the
* focused/rest dual mode comments below for why this gymnastic is needed.
*
* The `$` / `@` autocomplete popover is teleported to <body> and positioned
* with `position: fixed` so it escapes any ancestor `overflow: hidden`
* (e.g. the `.wp-rt` wrapper itself, scroll containers in editor tables).
*
* Reference: docs/design-handoff/wildcardpipeline/project/rich-input.jsx.
*/
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
interface Props {
@@ -50,7 +54,14 @@ const acQuery = ref("");
const acTrigger = ref<"$" | "@">("$");
const acStart = ref(-1);
const acActive = ref(0);
const acFlipUp = ref(false);
// Popup geometry — `position: fixed` viewport coordinates so the teleported
// popover lands directly under the input regardless of ancestor overflow.
const popupPos = ref<{ top: number; left: number; width: number; flipped: boolean }>({
top: 0,
left: 0,
width: 240,
flipped: false,
});
// --- Tokenize + mirror HTML (pre-escaped — see richTokenize.ts). ---
const tokens = computed(() => tokenizeRich(props.modelValue || ""));
@@ -100,13 +111,26 @@ function probeAutocomplete(str: string, caret: number): {
return { start: i, query: str.slice(i + 1, caret), trigger };
}
function maybeFlipUp(): void {
// Open downwards by default; flip if there isn't enough room below.
const wrap = inputEl.value?.parentElement;
if (!wrap) { acFlipUp.value = false; return; }
// --- Popup placement ---
// Anchor to the wrapper's bounding box. We use the input's parent (`.wp-rt`)
// rect rather than caret pixel coordinates for robustness — caret-pixel
// math against a textarea is error-prone and the prototype ships the same
// "below-the-input" behaviour. Flips above when there's no room below.
function positionPopup(): void {
const ta = inputEl.value;
if (!ta) return;
const wrap = ta.parentElement;
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const POPUP_H = 240; // matches max-height in CSS (keep in sync)
const spaceBelow = window.innerHeight - rect.bottom;
acFlipUp.value = spaceBelow < 200 && rect.top > 200;
const flipped = spaceBelow < POPUP_H && rect.top > POPUP_H;
popupPos.value = {
top: flipped ? rect.top - 4 : rect.bottom + 4,
left: rect.left,
width: Math.max(200, rect.width),
flipped,
};
}
// --- Handlers ---
@@ -122,7 +146,7 @@ function onInput(e: Event): void {
acQuery.value = hit.query;
acTrigger.value = hit.trigger;
acActive.value = 0;
maybeFlipUp();
positionPopup();
} else {
acOpen.value = false;
}
@@ -138,6 +162,7 @@ function refreshAutocompleteFromCaret(): void {
acStart.value = hit.start;
acQuery.value = hit.query;
acTrigger.value = hit.trigger;
positionPopup();
} else {
acOpen.value = false;
}
@@ -213,6 +238,42 @@ function onSuggestionMouseDown(e: MouseEvent, label: string): void {
e.preventDefault();
applyAutocomplete(label);
}
// --- Global listeners: close popup on outside-click / scroll / resize.
// We attach lazily (only while open) so non-editing inputs cost nothing.
function onDocumentMouseDown(e: MouseEvent): void {
const t = e.target as Node | null;
if (!t) return;
if (inputEl.value?.contains(t)) return;
if (popoverEl.value?.contains(t)) return;
acOpen.value = false;
}
function onWindowScroll(): void {
// Scrolls outside the textarea move the anchor → easier to just close.
acOpen.value = false;
}
function onWindowResize(): void {
if (acOpen.value) positionPopup();
}
watch(acOpen, (open) => {
if (open) {
void nextTick(positionPopup);
window.addEventListener("mousedown", onDocumentMouseDown, true);
window.addEventListener("scroll", onWindowScroll, true);
window.addEventListener("resize", onWindowResize);
} else {
window.removeEventListener("mousedown", onDocumentMouseDown, true);
window.removeEventListener("scroll", onWindowScroll, true);
window.removeEventListener("resize", onWindowResize);
}
});
onBeforeUnmount(() => {
window.removeEventListener("mousedown", onDocumentMouseDown, true);
window.removeEventListener("scroll", onWindowScroll, true);
window.removeEventListener("resize", onWindowResize);
});
</script>
<template>
@@ -273,31 +334,41 @@ function onSuggestionMouseDown(e: MouseEvent, label: string): void {
@blur="onBlur"
>
<div
v-if="acOpen && acItems.length > 0"
ref="popoverEl"
class="wp-rt-suggestions"
:class="{ 'wp-rt-suggestions--up': acFlipUp }"
role="listbox"
>
<div class="wp-rt-suggestions__head">
<span class="wp-rt-suggestions__query">{{ acTrigger }}{{ acQuery }}</span>
<span class="wp-rt-suggestions__hint">↑↓ Enter · Esc</span>
</div>
<button
v-for="(label, i) in acItems"
:key="label"
type="button"
class="wp-rt-suggestions__item"
:data-active="i === acActive ? '' : null"
role="option"
:aria-selected="i === acActive"
@mousedown="(e) => onSuggestionMouseDown(e, label)"
@mouseenter="acActive = i"
<!-- Teleport so the popover escapes ancestor overflow:hidden /
transformed scroll containers / table cells. -->
<Teleport to="body">
<div
v-if="acOpen && acItems.length > 0"
ref="popoverEl"
class="wp-rt-suggestions"
:class="{ 'wp-rt-suggestions--up': popupPos.flipped }"
:style="{
top: popupPos.top + 'px',
left: popupPos.left + 'px',
minWidth: popupPos.width + 'px',
}"
role="listbox"
>
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
</button>
</div>
<div class="wp-rt-suggestions__head">
<span class="wp-rt-suggestions__query">{{ acTrigger }}{{ acQuery }}</span>
<span class="wp-rt-suggestions__hint">↑↓ Enter · Esc</span>
</div>
<button
v-for="(label, i) in acItems"
:key="label"
type="button"
class="wp-rt-suggestions__item"
:data-active="i === acActive ? '' : null"
role="option"
:aria-selected="i === acActive"
@mousedown="(e) => onSuggestionMouseDown(e, label)"
@mouseenter="acActive = i"
>
<i :class="acTrigger === '$' ? 'pi pi-dollar' : 'pi pi-at'" class="wp-rt-suggestions__icon" />
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
</button>
</div>
</Teleport>
</div>
</template>
@@ -395,28 +466,45 @@ function onSuggestionMouseDown(e: MouseEvent, label: string): void {
/* Token span chrome (.wp-rt-var / .wp-rt-ref / .wp-rt-dp-* / quantifier
colour + chip backgrounds + focus/rest padding) lives in the global
styles/rich-text.css so RichTextInput and RichTextPreview stay in sync. */
</style>
/* Autocomplete popover -------------------------------------------------- */
<style>
/* Autocomplete popover -------------------------------------------------- *
* NOT scoped — the popover is teleported to <body>, so a scoped selector
* (which adds a `[data-v-…]` attribute) wouldn't match. The class names
* are component-specific (`wp-rt-suggestions*`) so global is fine.
* ------------------------------------------------------------------------ */
.wp-rt-suggestions {
position: absolute;
left: 8px;
right: 8px;
top: calc(100% + 4px);
z-index: 30;
position: fixed;
z-index: 1000;
min-width: 200px;
max-width: 360px;
max-height: 240px;
overflow-y: auto;
background: var(--wp-bg-1, #11111b);
border: 1px solid var(--wp-border-strong, rgba(255, 255, 255, 0.14));
border-radius: 8px;
padding: 4px;
box-shadow: var(--wp-shadow, 0 4px 16px rgba(0, 0, 0, 0.35));
box-shadow: var(--wp-shadow-lg, var(--wp-shadow, 0 10px 30px rgba(0, 0, 0, 0.45)));
display: flex;
flex-direction: column;
gap: 1px;
max-height: 240px;
overflow-y: auto;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12.5px;
animation: wp-rt-suggestions-in 0.12s ease-out;
}
.wp-rt-suggestions--up {
top: auto;
bottom: calc(100% + 4px);
/* `top` is set inline as the rect.top of the input; shift fully above. */
transform: translateY(-100%);
animation-name: wp-rt-suggestions-in-up;
}
@keyframes wp-rt-suggestions-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes wp-rt-suggestions-in-up {
from { opacity: 0; transform: translateY(calc(-100% + 4px)); }
to { opacity: 1; transform: translateY(-100%); }
}
.wp-rt-suggestions__head {
display: flex;
@@ -436,13 +524,15 @@ function onSuggestionMouseDown(e: MouseEvent, label: string): void {
opacity: 0.7;
}
.wp-rt-suggestions__item {
display: block;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: 4px;
padding: 4px 8px;
padding: 6px 8px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--wp-text, #e7e7ee);
@@ -451,6 +541,11 @@ function onSuggestionMouseDown(e: MouseEvent, label: string): void {
.wp-rt-suggestions__item[data-active] {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 22%, transparent);
}
.wp-rt-suggestions__icon {
font-size: 10px;
color: var(--wp-accent-text, #c4b5fd);
opacity: 0.85;
}
.wp-rt-suggestions__trigger {
color: var(--wp-accent-text, #c4b5fd);
}