Template Editor
A plain-paragraph editor for text with placeholders — @ opens a searchable token list, a picked token becomes a chip, and the value stays plain text.
- Status
- Since
0.23.72- Accessibility pattern
- multiline textbox + listbox picker via aria-activedescendant
Last updated
Type @ or {{ to insert a spec. Enter starts a new paragraph.
Recessed {{power}} downlight with {{efficacy}} efficacy and {{cri}} colour rendering.
Fits a {{cutout}} cut-out.Install
Add Template Editor from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/template-editorIt also adds the sanctioned engines to your package.json: @tiptap/react, @tiptap/starter-kit, @tiptap/pm, @tiptap/extensions, @tiptap/suggestion.
Usage
import { TemplateEditor } from "@/components/ui/template-editor";
const specs = [
{ id: "power", label: "Power", hint: "W" },
{ id: "efficacy", label: "Efficacy", hint: "lm/W" },
];
<Field>
<FieldLabel>Marketing description</FieldLabel>
<TemplateEditor
value={template}
onChange={setTemplate}
tokens={specs}
placeholder="Type @ to insert a spec"
/>
</Field>;The value is plain text. Each placeholder is written as {{id}}, paragraphs are separated by a
blank line (\n\n) and a single \n is a line break inside a paragraph, so
"Recessed {{power}} downlight\n\nFits a {{cutout}} cut-out." is two paragraphs with two chips.
The string round-trips exactly: rendering a value and changing nothing never calls onChange.
Scope
- Plain paragraphs only. No Markdown, bold, lists, links, toolbar or slash menu — reach for
TextEditwhen the text needs formatting. - Placeholders are atomic. A chip shows the token's
label, deletes with one Backspace, and is stored byid, so renaming a spec relabels every chip without touching the value. - Nothing is lost. An
{{id}}whose id is not intokensstill renders, as the raw id in the destructive style;invalidTokenIdsmarks known tokens the same way. - Filling the placeholders is the caller's job — the editor only writes the template.
Examples
Placeholders from @
Type @ (at the start of a word) or {{ (anywhere) to open the list, keep typing to filter it by
label, id or hint, then press Enter or Tab. The box below shows the stored
string.
Type @ or {{ to insert a spec. Enter starts a new paragraph.
Recessed {{power}} downlight with {{efficacy}} efficacy and {{cri}} colour rendering.
Fits a {{cutout}} cut-out.Empty
The placeholder shows until the first character, in the muted placeholder colour Textarea uses.
Unknown and invalid placeholders
invalidTokenIds turns a known token's chip destructive (a spec with no value on this product); an
id missing from tokens renders as its raw id in the same style.
Colour temperature has no value on this product, and “finish” is not a spec.
Invalid and disabled
Inside an invalid Field the border stays destructive, focused or not. disabled dims the box
and makes it read-only, like a disabled Textarea.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onChange* | (value: string) => void | — | Called with the new template string on every edit. Never called for an unchanged value. |
tokens* | readonly TemplateEditorToken[] | — | The placeholders @ or {{ offers, in the order the picker lists them. |
value* | string | — | The template: plain text with each placeholder written as {{id}}, and paragraphs separated
by a blank line (\n\n). A single \n is a line break inside a paragraph. |
aria-describedby | string | — | Id of the element that describes the editable surface. |
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the field invalid: a destructive border, like Textarea. |
aria-label | string | — | Accessible name of the editable surface. |
aria-labelledby | string | — | Id of the element that names the editable surface. |
className | string | — | Classes for the bordered root. |
disabled | boolean | false | Read-only and dimmed, like a disabled Textarea. |
id | string | — | The editable surface's id (an enclosing Field supplies one). |
invalidTokenIds | readonly string[] | — | Ids whose chips render in the destructive style (a placeholder the caller rejects). |
placeholder | string | — | Shown while the template is empty. |
ref | React.Ref<HTMLDivElement> | — | The bordered root element. |
Data attributes and CSS variables on TemplateEditor
| Attribute | Values |
|---|---|
data-disabled | "" |
data-invalid | "" |
data-slot | "template-editor" |
TemplateEditorToken
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | The stored identifier, written into the value as {{id}}. |
label* | string | — | What the chip and the picker show. |
hint | string | — | Muted text beside the label in the picker — a unit or a group. |
Accessibility
- The editable surface is a
role="textbox"witharia-multiline="true", named byaria-label,aria-labelledbyor an enclosingField's label; aField's description, error and invalid state reach it too. - The picker is a
listboxofoptions. Focus stays in the textbox, which points at the listbox witharia-controlsand at the highlighted option witharia-activedescendantwhile it is open. - The focus cue is text entry's: the box's border darkens subtly over 150ms and nothing fills
(
data-focus-cue="border"); an invalid box keeps its destructive border. Forced-colours mode restores a system outline.
| Key | Action |
|---|---|
| @ or {{ | Open the placeholder list |
| ↑ ↓ | Move through the list |
| Enter / Tab | Insert the highlighted placeholder (with the list open) |
| Esc | Close the list, keeping what was typed |
| Backspace / Del | Delete the chip before / after the caret as one unit |
| Enter | New paragraph (with the list closed) |
| Shift Enter | Line break inside the paragraph |
| Contract | States tested |
|---|---|
| Behaviour | default, empty, disabled, invalid, menu-open |
| Accessibility | labeled, described, disabled, invalid, semantic-html |
| Visual | default, empty, invalid, disabled, invalid-token |
Do / Don't
Text Edit
A Notion-style Tiptap markdown editor in body typography — slash and bubble menus, GFM tables, block drag handles, markdown shortcuts and an onCommit contract.
Field
The form-field scaffold — label, description, error, legend, separator and choice-card layouts in vertical, horizontal or responsive orientation.