feat(expr): ExpressionField with token highlight overlay and live preview

This commit is contained in:
Claude
2026-08-30 11:16:57 +00:00
parent 38aa05bc96
commit c502bdef03
2 changed files with 299 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from "vitest";
import { useRef, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ExpressionField, type ExpressionFieldHandle } from "./ExpressionField";
describe("ExpressionField", () => {
it("emits the raw text on change", async () => {
const onChange = vi.fn();
function Harness() {
const [v, setV] = useState("");
return (
<ExpressionField
value={v}
onChange={(next) => {
onChange(next);
setV(next);
}}
/>
);
}
render(<Harness />);
await userEvent.type(screen.getByRole("textbox"), "hi");
expect(onChange).toHaveBeenLastCalledWith("hi");
});
it("shows the live preview against previewInput", () => {
render(
<ExpressionField
value="hi {input.name}!"
onChange={() => {}}
previewInput={{ name: "alice" }}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!");
});
it("omits the preview line when previewInput is undefined", () => {
render(<ExpressionField value="hi {input.name}!" onChange={() => {}} />);
expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument();
});
it("shows a schema-validity warning for a bad integer literal", () => {
render(
<ExpressionField
value="abc"
onChange={() => {}}
schemaType="integer"
previewInput={{}}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i);
});
it("does not warn on integer literals that are valid", () => {
render(
<ExpressionField
value="200"
onChange={() => {}}
schemaType="integer"
previewInput={{}}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
});
it("does not warn on integer fields when the value contains a template", () => {
render(
<ExpressionField
value="{input.code}"
onChange={() => {}}
schemaType="integer"
previewInput={{ code: 200 }}
/>,
);
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
});
it("insertAtCaret splices text at the current caret position", async () => {
function Harness() {
const ref = useRef<ExpressionFieldHandle>(null);
const [v, setV] = useState("hello ");
return (
<>
<ExpressionField ref={ref} value={v} onChange={setV} />
<button onClick={() => ref.current?.insertAtCaret("{input.name}")}>insert</button>
</>
);
}
render(<Harness />);
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
await userEvent.click(screen.getByRole("button", { name: /insert/i }));
expect(ta.value).toBe("hello {input.name}");
});
it("single-line variant blurs on Enter instead of inserting a newline", async () => {
const onChange = vi.fn();
function Harness() {
const [v, setV] = useState("");
return (
<ExpressionField
value={v}
onChange={(next) => {
onChange(next);
setV(next);
}}
singleLine
/>
);
}
render(<Harness />);
const ta = screen.getByRole("textbox");
await userEvent.type(ta, "hi{Enter}");
// The Enter keydown was captured — no newline reached the value.
expect(onChange).toHaveBeenLastCalledWith("hi");
expect(document.activeElement).not.toBe(ta);
});
});

View File

@@ -0,0 +1,179 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { render as renderTemplate } from "./templateEngine";
export interface ExpressionFieldHandle {
insertAtCaret(text: string): void;
focus(): void;
}
export type ExpressionSchemaType = "string" | "integer" | "number" | "boolean";
interface Props {
value: string;
onChange: (v: string) => void;
onFocus?: () => void;
previewInput?: unknown;
schemaType?: ExpressionSchemaType;
singleLine?: boolean;
codeBlock?: boolean; // format:"code" fields — 10-row textarea, no Enter-blur
placeholder?: string;
id?: string;
}
export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function ExpressionField(
{ value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id },
ref,
) {
const taRef = useRef<HTMLTextAreaElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [overlayScroll, setOverlayScroll] = useState({ top: 0, left: 0 });
useImperativeHandle(ref, () => ({
focus() {
taRef.current?.focus();
},
insertAtCaret(text: string) {
const ta = taRef.current;
if (!ta) return;
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const next = ta.value.slice(0, start) + text + ta.value.slice(end);
onChange(next);
// Restore caret position after React re-renders the value.
requestAnimationFrame(() => {
ta.focus();
const caret = start + text.length;
ta.setSelectionRange(caret, caret);
});
},
}), [onChange]);
const rows = codeBlock ? 10 : singleLine ? 1 : 3;
const tokens = useMemo(() => tokenize(value), [value]);
const preview = useMemo(() => {
if (previewInput === undefined) return null;
if (schemaType !== "string" && value !== "" && value.indexOf("{") < 0) {
if (!isValidLiteral(value, schemaType)) {
return { kind: "warn" as const, text: `⚠ expected ${schemaType}` };
}
}
const rendered = renderTemplate(value, previewInput);
const truncated = rendered.length > 200 ? rendered.slice(0, 200) + "…" : rendered;
return { kind: "ok" as const, text: truncated };
}, [value, previewInput, schemaType]);
function syncScroll() {
const ta = taRef.current;
if (!ta) return;
setOverlayScroll({ top: ta.scrollTop, left: ta.scrollLeft });
}
useEffect(() => {
if (overlayRef.current) {
overlayRef.current.scrollTop = overlayScroll.top;
overlayRef.current.scrollLeft = overlayScroll.left;
}
}, [overlayScroll]);
return (
<div className="w-full">
<div className="relative">
<div
ref={overlayRef}
aria-hidden
className={
"absolute inset-0 pointer-events-none whitespace-pre-wrap break-words font-mono text-sm " +
"px-3 py-2 leading-5 text-slate-100 overflow-hidden"
}
>
{tokens.map((t, i) =>
t.kind === "expr"
? <span key={i} className="text-amber-300">{t.text}</span>
: <span key={i}>{t.text}</span>,
)}
{/* Trailing newline safeguard: keeps overlay height equal to the textarea when
the value ends in a newline. */}
{value.endsWith("\n") && <span> </span>}
</div>
<textarea
ref={taRef}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onScroll={syncScroll}
onKeyDown={(e) => {
if (singleLine && e.key === "Enter") {
e.preventDefault();
(e.target as HTMLTextAreaElement).blur();
}
}}
rows={rows}
placeholder={placeholder}
spellCheck={false}
className={
"relative w-full rounded bg-slate-950 border border-slate-800 " +
"px-3 py-2 outline-none focus:border-brand-500 " +
"font-mono text-sm leading-5 resize-none " +
"text-transparent caret-slate-100 selection:text-slate-100 selection:bg-brand-600/40"
}
style={{ WebkitTextFillColor: "transparent" }}
/>
</div>
{preview && (
<div
data-testid="expr-preview"
className={
"mt-1 text-xs font-mono truncate " +
(preview.kind === "warn" ? "text-amber-400" : "text-slate-500")
}
>
{preview.kind === "warn" ? preview.text : `→ ${preview.text}`}
</div>
)}
</div>
);
});
type Token = { kind: "text" | "expr"; text: string };
// Tokenizer for the overlay. Matches the executor's parsing shape: `{{` and
// `}}` are literal braces (still rendered as `{` / `}` in the "text" class),
// unclosed `{` is rendered as text, and `{...}` runs are the "expr" class.
function tokenize(s: string): Token[] {
const out: Token[] = [];
let i = 0;
let buf = "";
const flush = () => { if (buf) { out.push({ kind: "text", text: buf }); buf = ""; } };
while (i < s.length) {
const c = s[i];
if (c === "{") {
if (s[i + 1] === "{") { buf += "{"; i += 2; continue; }
const end = s.indexOf("}", i + 1);
if (end < 0) { buf += s.slice(i); break; }
flush();
out.push({ kind: "expr", text: s.slice(i, end + 1) });
i = end + 1;
continue;
}
if (c === "}" && s[i + 1] === "}") { buf += "}"; i += 2; continue; }
buf += c;
i++;
}
flush();
return out;
}
function isValidLiteral(v: string, t: ExpressionSchemaType): boolean {
if (t === "integer") {
if (!/^-?\d+$/.test(v)) return false;
return Number.isInteger(Number(v));
}
if (t === "number") {
const n = Number(v);
return Number.isFinite(n);
}
if (t === "boolean") return v === "true" || v === "false";
return true;
}