# TemplateEditor

**When to use:** Authoring an email or message template that mixes prose with placeholders resolved at send time, e.g. `Hello {{receiver.firstName}},`. The user types normal text and inserts placeholders from a grouped palette; each placed placeholder renders as an atomic chip, and the value stays a plain string that round-trips through any backend unchanged.

**Keywords:** merge fields, mail merge, interpolation, mentions, dynamic text, rich text, tags, macros

**Import:** `import { TemplateEditor, TemplateEditorPalettePosition, parseTemplate } from '@ahrowe/ui'`
**Types:** `import type { TemplateEditorToken, TemplateEditorTokenGroup, TemplateEditorProps, TemplateEditorLabels, TemplateEditorHandle, TemplateSegment } from '@ahrowe/ui'`

**Enums:**
- `TemplateEditorPalettePosition`: `Right` (default) | `Bottom` | `Menu` | `None`

**Insertion paths:** drag a palette entry into the text, click it, tab into the palette and press Enter, pick it from the menu that the `{ }` button in the field opens (`Menu`), insert it from a palette of your own through the `ref`, or type the opening delimiter (`{{` by default) to search inline. An already-placed chip can be dragged to a different position, in its own field or into another one, and moves there.

**Smart spacing:** a chip placed by any of those paths except typing gets a space on each side where it would otherwise touch a letter, a digit or another chip, so a chip dropped after `Hello` gives `Hello {{name}}` and one dropped between `Line` and `one` gives `Line {{name}} one`. Punctuation, whitespace and line edges get none: `Dear {{name}},`, `({{name}})` and `{{first}}.{{last}}@acme.com` keep their shape. A chip moved away or removed with its × takes the space it leaves redundant with it, so `Hello {{name}} how` becomes `Hello how` and `Line one {{name}}` becomes `Line one`. Typing, pasting, Backspace and Delete stay literal, the `{{` autocomplete included. The caret ends up right after the chip, and one undo takes the chip and its spaces back. Turn it off with `smartSpacing={false}` where chips must be able to touch, such as an address field holding `{{first}}{{last}}@acme.com`.

```tsx
import { useState } from 'react';
import { TemplateEditor } from '@ahrowe/ui';
import type { TemplateEditorTokenGroup } from '@ahrowe/ui';

const tokenGroups: TemplateEditorTokenGroup[] = [
  {
    id: 'receiver',
    label: 'Recipient',
    tokens: [
      { id: 'receiver.firstName', label: 'First name', sample: 'Jane' },
      { id: 'receiver.lastName', label: 'Last name', sample: 'Doe' },
      { id: 'receiver.email', label: 'Email' },
    ],
  },
  {
    id: 'sender',
    label: 'Sender',
    tokens: [
      { id: 'sender.firstName', label: 'First name' },
      { id: 'sender.email', label: 'Email' },
      { id: 'sender.userId', label: 'User ID', description: 'Internal only', disabled: true },
    ],
  },
];

function EmailTemplate() {
  const [body, setBody] = useState('Hello {{receiver.firstName}},\n\nI hope this email finds you well.');
  return (
    <TemplateEditor
      label="Email body"
      paletteLabel="Placeholders"
      tokenGroups={tokenGroups}
      value={body}
      onChange={setBody}
      rows={7}
    />
  );
}

// Palette underneath, for narrow layouts and touch
import { TemplateEditorPalettePosition } from '@ahrowe/ui';
<TemplateEditor
  tokenGroups={tokenGroups}
  palettePosition={TemplateEditorPalettePosition.Bottom}
  value={value}
  onChange={setValue}
/>

// No palette beside the field: a `{ }` button inside it opens the tokens as a menu.
// `multiline={false}` for a subject: Enter does nothing, pasted line breaks become spaces.
<TemplateEditor
  tokenGroups={tokenGroups}
  palettePosition={TemplateEditorPalettePosition.Menu}
  multiline={false}
  value={subject}
  onChange={setSubject}
/>

// Custom delimiters: anything the two strings can express, e.g. ERB or Handlebars-style
<TemplateEditor tokenGroups={tokenGroups} prefix="<%" suffix="%>" value={value} onChange={setValue} />
<TemplateEditor tokenGroups={tokenGroups} prefix="{{{" suffix="}}}" value={value} onChange={setValue} />

// Driven by a FormValidator instead of value/onChange, like every other input
import { FormValidator, Validators, useFormValidator } from '@ahrowe/ui';

const bodyValidator = useMemo(
  () => new FormValidator<string>('', [Validators.required('A body is required')]),
  [],
);
useFormValidator(bodyValidator);
<TemplateEditor label="Body" tokenGroups={tokenGroups} formValidator={bodyValidator} />

// Read only: chips stay readable and copyable, the palette is hidden
<TemplateEditor label="Locked template" tokenGroups={tokenGroups} value={value} readOnly />
```

**Sharing placeholders across several fields:** an email form usually wants the same placeholders in subject, body, and every address field. Two ways, depending on how much else the fields share:

```tsx
// 1. Hoist the groups and pass them to each field. Define them outside the
// component (or memoise them) so the array identity is stable across renders.
const TOKEN_GROUPS: TemplateEditorTokenGroup[] = [ /* … */ ];

<TemplateEditor label="Subject" tokenGroups={TOKEN_GROUPS} multiline={false} />
<TemplateEditor label="Body" tokenGroups={TOKEN_GROUPS} rows={8} />

// 2. Set them once on a ConfigProvider, and leave the prop off every field.
// Any other TemplateEditor prop can be defaulted the same way, and an explicit
// prop on a single field still wins.
import { ConfigProvider, TemplateEditorPalettePosition } from '@ahrowe/ui';

<ConfigProvider
  defaultProps={{
    TemplateEditor: {
      tokenGroups: TOKEN_GROUPS,
      palettePosition: TemplateEditorPalettePosition.Menu,
    },
  }}
>
  {/* Address fields: a space between two chips would break the address */}
  <TemplateEditor label="To" multiline={false} smartSpacing={false} />
  <TemplateEditor label="Bcc" multiline={false} smartSpacing={false} />
  <TemplateEditor label="Subject" multiline={false} />
  {/* This one opts back in to a palette */}
  <TemplateEditor label="Body" rows={8} palettePosition={TemplateEditorPalettePosition.Bottom} />
</ConfigProvider>
```

With more than two or three fields, give most of them `TemplateEditorPalettePosition.Menu` and show a palette only on the field where discovery matters, usually the body. The same palette beside every field is a lot of noise; a menu keeps each field one click from its tokens and inserts into that field. Dragging an entry out of the palette that is shown and dropping it into any other field works too: the drop carries the serialized placeholder, so the target renders it as a chip.

**A palette of your own:** with `palettePosition={TemplateEditorPalettePosition.None}` the field renders neither palette nor menu, and `ref.current.insertToken(id)` inserts where the caret last was in that field, replacing a selection, or at the end if the field was never focused. Clicking your palette moves focus away from the field; the insertion still lands where the caret was. One palette for a whole form inserts into the field the user was last in:

```tsx
import { useRef, useState } from 'react';
import { TemplateEditor, TemplateEditorPalettePosition } from '@ahrowe/ui';
import type { TemplateEditorHandle } from '@ahrowe/ui';

const subject = useRef<TemplateEditorHandle>(null);
const body = useRef<TemplateEditorHandle>(null);
const [target, setTarget] = useState(body);

<MyPalette onPick={(id) => target.current?.insertToken(id)} />
<TemplateEditor
  ref={subject}
  onFocus={() => setTarget(subject)}
  palettePosition={TemplateEditorPalettePosition.None}
  multiline={false}
  tokenGroups={tokenGroups}
  value={subjectValue}
  onChange={setSubjectValue}
/>
<TemplateEditor
  ref={body}
  onFocus={() => setTarget(body)}
  palettePosition={TemplateEditorPalettePosition.None}
  tokenGroups={tokenGroups}
  value={bodyValue}
  onChange={setBodyValue}
/>
```

Dragging from your own palette needs no ref: put the serialized placeholder on the drag, `event.dataTransfer.setData('text/plain', '{{receiver.email}}')`, and the field turns the drop into a chip. As with the built-in palette, dragging is desktop only, so keep a click path too.

**Preview:** when any token has a `sample`, an eye button in the field swaps every chip for its token's sample, so `Hello {{receiver.firstName}},` reads as `Hello Jane,` the way the recipient will see it. A token without a sample stays a chip. The field stays editable, the value does not change, and the chips' remove buttons are hidden so the text reads as the message. Each chip morphs into its sample and back, where the browser has view transitions with `view-transition-class`; elsewhere the swap is instant, and with reduced motion the two cross-fade in place. A sample wraps like the text around it, and a swap that changes how many lines the text takes is instant too: text cannot morph, only cross-fade, so every line after it would show twice. The button is there while read only too, and not while disabled. It is the editor's own preview: to show the finished message somewhere else as well, build it with `parseTemplate` (below). Without `sample`s there is no button; to keep the samples for the autocomplete but drop the button, hide its slot: `styles={{ previewButton: { display: 'none' } }}`.

The autocomplete shows each token's `sample` beside it, cut short where it is long, and marks the part of each entry that matched what was typed.

**Chip labels:** a chip is not under its group's heading the way a palette entry is, so a label that tokens in two groups share gets the group in front of it on the chip: `Recipient · First name` and `Sender · First name`, while a unique label such as `Email` stays as it is. Change the format with `labels.qualifiedToken`.

**Unknown placeholders:** a token present in `value` but absent from `tokenGroups` still renders as a chip, flagged with the warning colour and labelled with its raw id, and serializes back byte for byte. This keeps an imported or renamed placeholder visible instead of letting it decay into editable prose, and means `tokenGroups` can load asynchronously without the value flashing raw delimiters. Override the label with `labels.unknownToken`.

**Reading a template:** `parseTemplate(value, prefix?, suffix?)` splits a value into text and tokens exactly as the editor reads it, with the delimiters defaulting to `{{` and `}}`. Each `TemplateSegment` is `{ type: 'text', text }` or `{ type: 'token', id }`; a token carries no delimiters, so to show an unresolved one, write them back around its `id`. Use it rather than a regex of your own, so validation and sending agree with what the user saw.

```ts
import { parseTemplate } from '@ahrowe/ui';

// Block saving while the template uses a placeholder the backend does not know
const known = new Set(tokenGroups.flatMap((group) => group.tokens.map((token) => token.id)));
const unknown = parseTemplate(value).flatMap((segment) =>
  segment.type === 'token' && !known.has(segment.id) ? [segment.id] : [],
);

// Resolve at send time
const text = parseTemplate(value)
  .map((segment) => (segment.type === 'text' ? segment.text : (values[segment.id] ?? '')))
  .join('');
```

Everything between the delimiters is the id, so filter or fallback syntax such as `{{name | default: "there"}}` is not interpreted: it renders as an unknown chip unless that exact string is a token's `id`. An id never contains the opening delimiter, so in `Use {{ to start: {{receiver.email}}` the unclosed `{{` is text and only `receiver.email` is a token.

**TemplateEditorToken:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Required. The token body written between `prefix` and `suffix`, e.g. `receiver.firstName`. Must be unique across all groups |
| `label` | `string` | Text shown on the chip and in the palette. Falls back to `id` |
| `description` | `string` | Secondary line in the palette and the autocomplete list |
| `sample` | `string` | Example of what the token resolves to at send time, e.g. `Jane`. Shown in place of the chip while the preview is on, and beside the token in the autocomplete |
| `disabled` | `boolean` | Greys the palette entry out and leaves the token out of the menu and the autocomplete. Typed, pasted or dropped as text, it still becomes a chip, as it does already in the value: enforce what a field may contain with a validator, see **Reading a template** |

**TemplateEditorTokenGroup:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Stable identity used as the React key. Falls back to `label`, then the array index |
| `label` | `string` | Section heading. Omit for an unheaded group of loose tokens |
| `tokens` | `TemplateEditorToken[]` | Required. The tokens in this section, in display order |

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `value` | `string` | The template string. Controlled exactly like `Input`'s `value` |
| `onChange` | `(value: string) => void` | Fires with the full serialized template on every edit. Never fires with a value the component was just given |
| `tokenGroups` | `TemplateEditorTokenGroup[]` | Tokens offered by the palette and the autocomplete |
| `palettePosition` | `TemplateEditorPalettePosition` | Where the palette sits (default `Right`). `Right` moves it under the field once the two no longer fit side by side, at about 540px of the editor's own width (not the window's): the field needs 320px and the palette `--template-editor-palette-width`, 200px. `Menu` puts it behind a button in the field; `None` renders neither, for a palette of your own |
| `paletteLabel` | `string` | Heading above the palette |
| `prefix` | `string` | Opening delimiter (default `{{`). Typing it opens the autocomplete. Regex metacharacters are escaped |
| `suffix` | `string` | Closing delimiter (default `}}`) |
| `label` | `string` | Floating field label |
| `isRequired` | `boolean` | Appends a `*` to the label. Implied when `formValidator` carries a `required` validator |
| `errorMessage` | `string` | Error text shown in the field's error tooltip. Ignored when `formValidator` is set |
| `isValid` | `boolean` | Renders the error state without a message. Ignored when `formValidator` is set |
| `formValidator` | `FormValidator \| null` | Drives value, validation, and touched state instead of `value`/`onChange` |
| `placeholder` | `string` | Hint shown inside the empty, focused field |
| `disabled` | `boolean` | Blocks every edit and drag path, and dims the field |
| `readOnly` | `boolean` | Blocks editing but keeps the field focusable, selectable, and copyable. The palette is hidden |
| `multiline` | `boolean` | Allow line breaks (default `true`). Turn it off for a subject or an address: Enter then does nothing, and line breaks in pasted or dropped text become spaces |
| `rows` | `number` | Minimum visible text rows before the field grows (default `4`). Ignored when `multiline` is off |
| `autoFocus` | `boolean` | Focuses the field and places the caret at the end on mount |
| `smartSpacing` | `boolean` | Spaces a placed chip from the word it would touch, and tidies the space a moved or removed chip leaves (default `true`). See **Smart spacing** |
| `labels` | `TemplateEditorLabels` | Text the component renders of its own, see below |
| `ref` | `Ref<TemplateEditorHandle>` | `insertToken(id)`, for a palette of your own |

**Labels:** every string the component renders of its own, per key, also settable app-wide through `ConfigProvider`'s `TemplateEditor.labels`.

| Key | Type | Default |
|-----|------|---------|
| `palette` | `string` | `Placeholders`, the palette's accessible name when `paletteLabel` is not set |
| `insertToken` | `string` | `Insert placeholder`, the name and tooltip of the menu button |
| `preview` | `string` | `Preview with sample data`, the name and tooltip of the preview button |
| `removeToken` | `(label: string) => string` | `Remove ${label}`, read out for a chip's remove button |
| `qualifiedToken` | `(group: string, label: string) => string` | `${group} · ${label}`, the chip text of a token whose label another group's token shares |
| `unknownToken` | `(id: string) => string` | The raw id, shown on the chip of a token absent from `tokenGroups` |
| `unknownTokenTitle` | `(id: string) => string` | `${id} (unknown placeholder)`, that chip's tooltip |

```tsx
<TemplateEditor
  tokenGroups={tokenGroups}
  value={value}
  onChange={setValue}
  labels={{ palette: 'Platzhalter', removeToken: (label) => `${label} entfernen` }}
/>
```

**Behaviour worth knowing:**

- **Plain text only.** Paste is stripped to `text/plain` and re-tokenized, so a pasted `{{receiver.email}}` becomes a chip. There is no bold, italic, or list formatting.
- **Chips are atomic.** The caret steps over a chip in a single arrow press rather than stopping inside it, one Backspace or Delete next to a chip removes the whole placeholder rather than part of its label, and each chip carries a remove button for pointer and touch users, hidden while the field is read only or disabled. Shift+Arrow still extends a selection across a chip normally, and a chip inside a selection is drawn selected as a whole, in the colour the browser selects text in. Clicking a chip puts the caret on its nearer side, so two chips with nothing between them can still be typed between.
- **Copy and cut write the placeholder, not the label**, so copying a chip and pasting it back yields `{{receiver.email}}` rather than the word "Email".
- **Undo and redo are owned by the component** (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y). Consecutive typing collapses into one step; each insertion, deletion, paste, and drop is its own step. A value set from outside, such as a form reset or a loaded template, starts a new history, as it does in a native textarea.
- **The palette is a toolbar.** Tab reaches it once; the arrow keys, Home and End move between entries and skip disabled ones; Enter or Space inserts.
- **The palette points at its chips.** Pointing at an entry or focusing it rings each of its chips in the field beside it.
- **Moving a placed chip by dragging is desktop only.** HTML5 drag events do not fire on touch devices. Clicking a palette entry and typing the opening delimiter both work everywhere, so no insertion path is lost on touch.
- **Single-character delimiters** such as `{` and `}` are accepted but impractical: the autocomplete then opens on almost any occurrence of that character in prose.

**Theming:** these are theme variables. Set them theme-wide via `ThemeProvider`'s `variables`, or per instance via `style`. Each falls back to a built-in default when unset:

| Variable | Falls back to |
|----------|---------------|
| `--template-editor-chip-background` | a 22% tint of `--primary-color`, as a Light `Chip` |
| `--template-editor-chip-color` | `--primary-color`, a third of the way to `--text-color` |
| `--template-editor-unknown-chip-background` | a 22% tint of `--warn-color` |
| `--template-editor-unknown-chip-color` | `--warn-color`, a third of the way to `--text-color` |
| `--template-editor-palette-width` | `200px` |
| `--template-editor-gap` | `16px` (between the field and the palette) |

**Global defaults:** supported via `ConfigProvider` under the `TemplateEditor` key. Because the field is built on `Input`, a global `Input` default also applies to the props it forwards, exactly as it does for `Textarea` and `NumberInput`.

**Slots:** `root` `label` `container` `fieldset` `editable` `chip` `chipRemove` `menuButton` `previewButton` `palette` `paletteLabel` `paletteGroup` `paletteGroupLabel` `paletteItem` `suggestions` `suggestionGroupLabel` `suggestionItem`
