feat(injector): rich text input for the template, scoped to this row's refs

The binding modal's template was a bare <textarea>, so it had no `$`
autocomplete at all -- reported as "the $ variable autocomplete does not work
in the context injector". Nothing was broken; the machinery had simply never
been attached to that field.

The suggestion pool was already sitting next to it. `insertOptions` builds the
"+ $ref" menu from this row's own references -- a socket row can reference only
its own slot, the general row can reference every socket -- and the
autocomplete now reads the SAME list, so the menu and the popover cannot drift.
Deliberately not the library's variables: nothing in this modal can resolve
one, and offering them would suggest bindings that silently do nothing.

Surface is `assembler` because this composes a template. Explicitly not
`wildcard`, which suppresses `$` on purpose.

`insertSlotRef` no longer splices at the caret. RichTextInput is
contenteditable with atom spans, so `selectionStart` does not exist and the old
path had nothing to splice into; it appends instead. Honest about what it does,
and the menu is the fallback now that typing `$` opens the same list in place.

Tests updated rather than worked around: seven drove the field as an
HTMLTextAreaElement. The modal's own suite stubs RichTextInput in its mount
helper the way the combine editor's tests do, and the two widget-level tests
drive the component directly, since jsdom does not implement contenteditable
editing and those tests care that the value reaches the persisted JSON rather
than how the caret moved.
This commit is contained in:
DumiFlex
2026-08-07 00:51:00 +03:00
parent e2b9f2f053
commit 8aea38dd45
3 changed files with 87 additions and 37 deletions
@@ -4,6 +4,31 @@ import { nextTick } from "vue";
import InjectorBindingModal from "./InjectorBindingModal.vue";
import type { InjectorRow } from "../../widgets/_shared";
// The template field is a RichTextInput now, not a <textarea>: it needs the
// `$` handler, and the ref list that already fed the "+ $ref" menu is exactly
// the right suggestion pool. Stubbed the same way the combine editor's tests
// stub it — assert prop wiring and simulate emits rather than driving
// contenteditable in jsdom.
const RichTextInputStub = {
name: "RichTextInput",
props: ["modelValue", "varSuggestions", "multiline", "rows", "surface", "placeholder", "ariaLabel"],
emits: ["update:modelValue"],
template: `<div class="wp-rt-stub" data-test="ibm-template" :data-model-value="modelValue"></div>`,
};
const globalStubs = { RichTextInput: RichTextInputStub };
/** Read what the template field is currently showing. */
function templateValue(w: any): string | undefined {
return w.findComponent(RichTextInputStub).props("modelValue");
}
/** Type into the template field. */
async function typeTemplate(w: any, value: string): Promise<void> {
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", value);
await w.vm.$nextTick();
}
// The insert menu is `<Teleport to="body">` — it escapes the modal's scroll
// container so it can't be clipped, which means it is NOT inside the test
// wrapper's subtree. Query the document for it instead.
@@ -25,7 +50,18 @@ async function clickMenuItem(name: string): Promise<void> {
const mounted: { unmount: () => void }[] = [];
type MountArgs = Parameters<typeof mount<typeof InjectorBindingModal>>;
function mountModal(...args: MountArgs): ReturnType<typeof mount<typeof InjectorBindingModal>> {
const w = mount(...args);
// Stub RichTextInput for every mount here. It is contenteditable with atom
// spans, which jsdom does not implement, and these tests are about the
// modal's own wiring rather than the editor's internals.
const [component, options] = args as [unknown, Record<string, any> | undefined];
const merged = {
...(options ?? {}),
global: {
...((options?.global as Record<string, unknown>) ?? {}),
stubs: { ...globalStubs, ...((options?.global as any)?.stubs ?? {}) },
},
};
const w = mount(component as never, merged as never);
mounted.push(w);
return w;
}
@@ -77,13 +113,13 @@ describe("InjectorBindingModal — template section", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: makeRow({ template: "i love $input_1" }) },
});
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(ta.element.value).toBe("i love $input_1");
const shown = templateValue(w);
expect(shown).toBe("i love $input_1");
});
it("Save emits a non-empty template string verbatim (no null collapse)", async () => {
const w = mountModal(InjectorBindingModal, { props: { row: makeRow() } });
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("i love $input_2");
await typeTemplate(w, "i love $input_2");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -95,7 +131,7 @@ describe("InjectorBindingModal — template section", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: makeRow({ template: "hello" }) },
});
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue(" ");
await typeTemplate(w, " ");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -142,8 +178,8 @@ describe("InjectorBindingModal — template section", () => {
props: { row: makeRow({ template: "hi" }) },
});
await w.find('[data-test="ibm-template-reset"]').trigger("click");
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(ta.element.value).toBe("");
const shown = templateValue(w);
expect(shown).toBe("");
});
it("typing a bare $ does NOT crash the tokenizer (regression)", () => {
@@ -252,7 +288,7 @@ describe("InjectorBindingModal — general (template) row", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: generalRow({ binding: "combo", template: "$input_0" }), references: ["input_0", "test"] },
});
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("$input_0 by $test");
await typeTemplate(w, "$input_0 by $test");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -22,6 +22,7 @@
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import type { InjectorRow } from "../../widgets/_shared";
import RichTextInput from "../../manager/components/RichTextInput.vue";
const props = withDefaults(
defineProps<{
@@ -144,7 +145,6 @@ const insertOptions = computed<InsertOption[]>(() => {
}];
});
const taRef = ref<HTMLTextAreaElement | null>(null);
const showInsertMenu = ref(false);
// Draft buffer — mirrors the module InstanceModal pattern. Edits land
@@ -175,33 +175,36 @@ function onBindingInput(ev: Event): void {
draftBinding.value = (ev.target as HTMLInputElement).value;
}
function onTemplateInput(ev: Event): void {
draftTemplate.value = (ev.target as HTMLTextAreaElement).value;
}
/**
* The `$` pool for this modal, and ONLY this modal's refs.
*
* A socket row can reference just its own slot; the general row can reference
* every socket. That is the same rule `insertOptions` already encodes for the
* "+ $ref" menu, so the autocomplete and the menu cannot drift apart — they
* read the same list. Deliberately not the library's variables: nothing here
* can resolve one.
*/
const refSuggestionNames = computed(() => insertOptions.value.map((o) => o.slotName));
function onResetTemplate(): void {
draftTemplate.value = "";
}
/**
* The "+ $ref" menu still appends, but no longer reaches into a textarea's
* selection to do it.
*
* RichTextInput owns its own caret — it is contenteditable with atom spans,
* not a plain text box, so `selectionStart` does not exist and the old
* splice-at-cursor path had nothing to splice into. Appending is honest about
* what it does; the menu is now the fallback anyway, since typing `$` opens
* the same list in place.
*/
function insertSlotRef(slotName: string): void {
const ta = taRef.value;
const insertion = `$${slotName}`;
const current = templateValue.value;
let next: string;
if (ta && typeof ta.selectionStart === "number") {
const start = ta.selectionStart;
const end = ta.selectionEnd ?? start;
next = current.slice(0, start) + insertion + current.slice(end);
const caret = start + insertion.length;
queueMicrotask(() => {
ta.focus();
ta.setSelectionRange(caret, caret);
});
} else {
next = current + insertion;
}
const sep = current && !current.endsWith(" ") ? " " : "";
closeInsertMenu();
draftTemplate.value = next;
draftTemplate.value = `${current}${sep}$${slotName}`;
}
function onSave(): void {
@@ -478,18 +481,26 @@ function onKeydown(ev: KeyboardEvent): void {
</div>
</div>
<textarea
ref="taRef"
<!-- RichTextInput rather than a bare <textarea>: the `$` handler it
brings is exactly what this field needs, and the ref list feeding
the "+ $ref" menu beside it was already the right suggestion pool
— it simply had nothing to feed. `assembler` surface because this
composes a template; it is NOT `wildcard`, which suppresses `$`
on purpose. -->
<RichTextInput
class="ibm__template"
:class="{ 'ibm__template--set': !!templateValue }"
data-test="ibm-template"
:value="templateValue"
:model-value="templateValue"
surface="assembler"
multiline
:rows="3"
:var-suggestions="refSuggestionNames"
:placeholder="isGeneral
? 'e.g. $input_0 by $test — composed after all socket rows'
: 'e.g. i love $input_0 — leave empty to pass the raw socket value'"
aria-label="Template"
rows="3"
@input="onTemplateInput"
@update:model-value="(v: string) => (draftTemplate = v)"
/>
<div v-if="templateValue" class="ibm__detected">
@@ -114,10 +114,13 @@ describe("InjectorWidget — template persistence (Phase 6)", () => {
editItem!.click();
await w.vm.$nextTick();
const textarea = document.querySelector<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(textarea).not.toBeNull();
textarea!.value = "i love $input_0";
textarea!.dispatchEvent(new Event("input"));
// The template field is a RichTextInput (contenteditable) rather than a
// <textarea>, so drive the component instead of assigning `.value`.
// jsdom does not implement contenteditable editing, and this test cares
// that the value reaches the persisted JSON, not how the caret moved.
const rt = w.findComponent({ name: "RichTextInput" });
expect(rt.exists()).toBe(true);
rt.vm.$emit("update:modelValue", "i love $input_0");
await w.vm.$nextTick();
const saveBtn = document.querySelector<HTMLButtonElement>('[data-test="ibm-save"]');