diff --git a/frontend/src/expr/ExpressionField.test.tsx b/frontend/src/expr/ExpressionField.test.tsx new file mode 100644 index 0000000..c3c3968 --- /dev/null +++ b/frontend/src/expr/ExpressionField.test.tsx @@ -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 ( + { + onChange(next); + setV(next); + }} + /> + ); + } + render(); + await userEvent.type(screen.getByRole("textbox"), "hi"); + expect(onChange).toHaveBeenLastCalledWith("hi"); + }); + + it("shows the live preview against previewInput", () => { + render( + {}} + previewInput={{ name: "alice" }} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!"); + }); + + it("omits the preview line when previewInput is undefined", () => { + render( {}} />); + expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument(); + }); + + it("shows a schema-validity warning for a bad integer literal", () => { + render( + {}} + schemaType="integer" + previewInput={{}} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i); + }); + + it("does not warn on integer literals that are valid", () => { + render( + {}} + schemaType="integer" + previewInput={{}} + />, + ); + expect(screen.getByTestId("expr-preview")).toHaveTextContent("200"); + }); + + it("does not warn on integer fields when the value contains a template", () => { + render( + {}} + 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(null); + const [v, setV] = useState("hello "); + return ( + <> + + + + ); + } + render(); + 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 ( + { + onChange(next); + setV(next); + }} + singleLine + /> + ); + } + render(); + 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); + }); +}); diff --git a/frontend/src/expr/ExpressionField.tsx b/frontend/src/expr/ExpressionField.tsx new file mode 100644 index 0000000..4ade9ee --- /dev/null +++ b/frontend/src/expr/ExpressionField.tsx @@ -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(function ExpressionField( + { value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id }, + ref, +) { + const taRef = useRef(null); + const overlayRef = useRef(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 ( +
+
+
+ {tokens.map((t, i) => + t.kind === "expr" + ? {t.text} + : {t.text}, + )} + {/* Trailing newline safeguard: keeps overlay height equal to the textarea when + the value ends in a newline. */} + {value.endsWith("\n") && } +
+