# Editor

A rich-text field with a formatting toolbar: headings, marks, lists, quotes and links, read and written as HTML.

- **Import:** `import { Editor } from "@booleanpress/ui/editor"`
- **Also install:** `@tiptap/react`, `@tiptap/pm`, `@tiptap/starter-kit`
- **APG Toolbar:** <https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/>
- **Page:** <https://ui.booleanpress.com/components/editor> · @booleanpress/ui 0.2.0

## Usage

The editor is built on Tiptap 3, whose packages load only with this entry.

```tsx
import { Editor } from "@booleanpress/ui/editor"

export function TemplateBody({ html, onChange }) {
  return (
    <Editor
      aria-label="Email body"
      value={html}
      onChange={onChange}
      placeholder="Write the email…"
    />
  )
}
```

The value is HTML. Pass `defaultValue` and let the editor keep it, or `value` and `onChange` to control it; `onChange` receives `""` once the text is empty, so a required check works on the value directly. `onJsonChange` receives Tiptap's JSON as well. `name` adds a hidden field holding the HTML, for a form that posts without JavaScript handling. Clean the HTML on the server before you show it to anyone else: the editor only writes the elements of its toolbar (pasted or given HTML is reduced to them, so scripts, event attributes and `javascript:` addresses do not survive), but a request can carry anything.

Name the editor with `aria-label`, or point `aria-labelledby` at a visible label: a label's `htmlFor` cannot reach an editable `div`. `aria-invalid` and `aria-describedby` (an error message) go on the text, as on any field. `readOnly` shows the content without a toolbar, selectable and in the tab order; `disabled` turns everything off. `characterCount` writes the number of characters under the text, and `maxLength` refuses an edit that would pass it. `toolbar` chooses the controls, one inner list per group (`[["bold", "italic"], ["link"]]`), or hides the toolbar with `false`; the default holds `heading` (a select of paragraph and headings 1 to 3), `bold`, `italic`, `underline`, `strike`, `code`, `bulletList`, `orderedList`, `blockquote`, `link`, `undo` and `redo`. `size` and `variant="filled"` follow the other fields, and `contentClassName` sizes the text area (`h-80` scrolls inside a fixed height; the default grows from 160 px).

The link button opens a small form for the address. A bare `example.com` becomes `https://example.com` and a bare e-mail address a `mailto:` link; anything else that is not a web, `mailto:` or `tel:` address is refused with a message. With no text selected, the address itself is inserted as the link. Markdown-style shortcuts work as you type: `# ` makes a heading, `- ` a list, `> ` a quote.

## Examples

### Basic

An email template with a heading, marks, a list, a quote and a link, in a 320 px area that scrolls.

```tsx
import { Editor } from "@booleanpress/ui/editor"

const TEMPLATE = `<h2>Your password reset link</h2>
<p>Hi {{first_name}},</p>
<p>Someone asked to reset the password of your <strong>Northwind</strong> account. The link below works for <em>one hour</em>:</p>
<ul><li>Open the link on the device you sign in with.</li><li>Choose a password you have not used before.</li></ul>
<blockquote>If you did not ask for this, you can ignore this email.</blockquote>
<p>Questions? Read the <a href="https://example.com/help">help centre</a>.</p>`

export default function EditorBasic() {
  return <Editor aria-label="Email template" defaultValue={TEMPLATE} contentClassName="h-80" className="max-w-2xl" />
}
```

### Controlled

`value` and `onChange` keep the HTML in state; it is shown under the editor as it changes.

```tsx
import * as React from "react"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorControlled() {
  const [html, setHtml] = React.useState("<p>Thanks for your patience: the <strong>DKIM records</strong> are now live.</p>")

  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      <Editor aria-label="Reply to Maya Chen" value={html} onChange={setHtml} contentClassName="min-h-32" />
      <div className="flex flex-col gap-1">
        <span className="text-xs font-medium text-muted-foreground">HTML sent to the server</span>
        <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">
          {html || "(empty)"}
        </pre>
      </div>
    </div>
  )
}
```

### Placeholder

`placeholder` shows a hint while the editor is empty.

```tsx
import { Editor } from "@booleanpress/ui/editor"

export default function EditorPlaceholder() {
  return <Editor aria-label="Reply" placeholder="Write a reply to the customer…" contentClassName="min-h-32" className="max-w-2xl" />
}
```

### Read only

`readOnly` shows release notes without a toolbar; the text can be selected and copied.

```tsx
import { Editor } from "@booleanpress/ui/editor"

const NOTES = `<h3>Release 4.2</h3>
<ul><li><strong>Templates:</strong> open rate per template in the monthly report.</li><li><strong>Logs:</strong> bounces now show the receiving server's reply.</li></ul>
<p>Read the full notes in the <a href="https://example.com/changelog">changelog</a>.</p>`

export default function EditorReadOnly() {
  return <Editor aria-label="Release notes" readOnly defaultValue={NOTES} contentClassName="min-h-0" className="max-w-2xl" />
}
```

### Minimal toolbar

`toolbar` keeps bold, italic, underline, a bulleted list and the link.

```tsx
import { Editor } from "@booleanpress/ui/editor"

export default function EditorMinimalToolbar() {
  return (
    <Editor
      aria-label="Internal note"
      toolbar={[["bold", "italic", "underline"], ["bulletList"], ["link"]]}
      placeholder="Add a note only your team can see"
      contentClassName="min-h-24"
      className="max-w-xl"
    />
  )
}
```

### Character count with limit

`characterCount` with `maxLength={200}` counts the characters under the text and refuses more.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorCharacterCount() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-2">
      <Label id="summary-label">Ticket summary</Label>
      <Editor
        aria-labelledby="summary-label"
        toolbar={[["bold", "italic"], ["link"]]}
        characterCount
        maxLength={200}
        defaultValue="<p>Password reset emails bounce since the sender moved to a domain without DKIM records.</p>"
        contentClassName="min-h-24"
      />
    </div>
  )
}
```

### Invalid

`aria-invalid` turns the edge red; `aria-describedby` points at the error message.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Editor } from "@booleanpress/ui/editor"

export default function EditorInvalid() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-2">
      <Label id="reply-label">Reply</Label>
      <Editor
        aria-labelledby="reply-label"
        aria-invalid
        aria-describedby="reply-error"
        toolbar={[["bold", "italic", "underline"], ["link"]]}
        contentClassName="min-h-24"
      />
      <p id="reply-error" className="text-xs text-destructive-strong">
        Write a reply before you send it.
      </p>
    </div>
  )
}
```

### Disabled

`disabled` greys the field and turns off the text and every control.

```tsx
import { Editor } from "@booleanpress/ui/editor"

export default function EditorDisabled() {
  return (
    <Editor
      aria-label="Signature"
      disabled
      defaultValue="<p><strong>Sam Ortiz</strong><br>Support, Northwind Mail</p>"
      contentClassName="min-h-24"
      className="max-w-2xl"
    />
  )
}
```

### Sizes

`size` `sm`, `default` and `lg`: 12, 14 and 16 px text, with the toolbar scaled to match.

```tsx
import { Editor, type EditorTool } from "@booleanpress/ui/editor"

const TOOLS: EditorTool[][] = [["bold", "italic", "link"]]

export default function EditorSizes() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <Editor aria-label="Small note" size="sm" toolbar={TOOLS} defaultValue="<p>Small: 12 px text.</p>" contentClassName="min-h-16" />
      <Editor aria-label="Default note" toolbar={TOOLS} defaultValue="<p>Default: 14 px text.</p>" contentClassName="min-h-16" />
      <Editor aria-label="Large note" size="lg" toolbar={TOOLS} defaultValue="<p>Large: 16 px text.</p>" contentClassName="min-h-16" />
    </div>
  )
}
```

### Filled

`variant="filled"` draws the grey field fill.

```tsx
import { Editor } from "@booleanpress/ui/editor"

export default function EditorFilled() {
  return (
    <Editor
      aria-label="Auto-reply message"
      variant="filled"
      defaultValue="<p>We have your message and reply within one working day.</p>"
      contentClassName="min-h-32"
      className="max-w-2xl"
    />
  )
}
```

### With a form

`name` submits the HTML; a disabled editor submits nothing, as a disabled field does; the form's Reset puts `defaultValue` back.

```tsx
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { Editor } from "@booleanpress/ui/editor"
import { Label } from "@booleanpress/ui/label"
import { Switch } from "@booleanpress/ui/switch"

export default function EditorWithForm() {
  const [disabled, setDisabled] = React.useState(false)
  const [sent, setSent] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-2xl flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSent([...new FormData(event.currentTarget)].map(([key, value]) => `${key} = ${value}`).join(", "))
      }}
    >
      <Editor aria-label="Reply" name="reply" defaultValue="<p>Initial text</p>" disabled={disabled} contentClassName="min-h-24" />
      <div className="flex items-center gap-2">
        <Switch id="reply-disabled" checked={disabled} onCheckedChange={setDisabled} />
        <Label htmlFor="reply-disabled">Disable</Label>
      </div>
      <div className="flex gap-2">
        <Button type="submit">Submit</Button>
        <Button type="reset" variant="outline">
          Reset
        </Button>
      </div>
      <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">
        {sent === null ? "Press Submit to see what the form sends." : `Sent: ${sent || "(nothing)"}`}
      </pre>
    </form>
  )
}
```

## Accessibility

**Semantics.** The text is an editable `div` with `role="textbox"` and `aria-multiline="true"`, carrying the name, `aria-describedby`, `aria-invalid`, `aria-readonly`, `aria-disabled` and `aria-placeholder`. The toolbar is a `role="toolbar"` that names the text it controls (`aria-controls`); format buttons are toggle buttons with `aria-pressed` and their shortcut in `aria-keyshortcuts`; the text style is a select (`role="combobox"`). The link form is a dialog with a labelled field; a refused address sets `aria-invalid` and describes the field with the message.

**Labels.** Name the editor with `aria-label` or `aria-labelledby`. The toolbar is named by `editorToolbar`, the select by `textStyle` (its options `paragraph` and `heading`, "Heading {level}"), the buttons by `bold`, `italic`, `underline`, `strikethrough`, `inlineCode`, `bulletList`, `orderedList`, `blockquote`, `link`, `undo` and `redo`. The link form uses `linkUrl`, `invalidLink`, `apply` and `removeLink`; the counter `characterCount` ("{count} of {max} characters") or `characters`.

**Focus.** The toolbar is one tab stop: Tab lands on its last-used control, the arrows move between controls, and Tab again enters the text. A toolbar button pressed with the keyboard keeps focus; pressed with the pointer, the caret stays in the text. Choosing a text style with the pointer puts the caret back in the text. The link form takes focus when it opens; applying or removing a link returns focus to the text, and Escape returns it to where the form was opened from. The field's edge turns `--ring` while the text has focus.

**Known limits.**

- Tiptap renders in the browser after the page loads; on the server the toolbar and an empty text area are drawn, then the content appears.
- A paste that would pass `maxLength` is refused whole, not cut to fit.
- The counter is read with the field's description when the text is focused; it is not announced on every key.
- In a list item that can be indented, Tab indents it and Shift+Tab outdents it instead of leaving the editor; Enter on an empty item leaves the list.
- Read-only mode hides the toolbar. Links in read-only text open with a normal click; in editable text they do not, so a click places the caret.
- Pressed formats are drawn in the primary colour on a faint plate; the visual target uses the colour alone.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Tab | Moves into the toolbar (one stop), then into the text; Shift+Tab goes back. |
| → or ← | In the toolbar, moves to the next or previous control, skipping disabled ones. Reversed in a right-to-left page. |
| Home or End | In the toolbar, moves to the first or last control. |
| Enter or Space | On a format button, turns the format on or off, keeping focus on the button; on the text style, opens its list. |
| Ctrl + B | Bold (⌘B on a Mac). |
| Ctrl + I | Italic (⌘I on a Mac). |
| Ctrl + U | Underline (⌘U on a Mac). |
| Ctrl + Shift + S | Strikethrough (⌘⇧S on a Mac). |
| Ctrl + E | Inline code (⌘E on a Mac). |
| Ctrl + Alt + 1–3 | Heading 1, 2 or 3 (⌘⌥1–3 on a Mac). |
| Ctrl + Alt + 0 | Back to a paragraph (⌘⌥0 on a Mac). |
| Ctrl + Shift + 8 | Bulleted list (⌘⇧8 on a Mac). |
| Ctrl + Shift + 7 | Numbered list (⌘⇧7 on a Mac). |
| Ctrl + Shift + B | Quote (⌘⇧B on a Mac). |
| Ctrl + K | Opens the link form for the selection (⌘K on a Mac). |
| Ctrl + Z | Undo (⌘Z on a Mac). |
| Ctrl + Shift + Z | Redo; Ctrl+Y as well (⌘⇧Z on a Mac). |
| Enter | In the link form, applies the address, or shows why it is refused. |
| Escape | Closes the link form or the text-style list without a change. |

## API

### Editor

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `characterCount` | `boolean` | `false` | Shows the number of characters under the text ("12 of 200 characters" with `maxLength`). |
| `contentClassName` | `string` |  | Classes for the area that holds the text, such as a height (`h-80`) or a minimum height. |
| `defaultValue` | `string` |  | The content to start with, as HTML, uncontrolled. |
| `disabled` | `boolean` | `false` | Stops editing and every toolbar control. |
| `form` | `string` |  | The id of the form that field belongs to, for an editor placed outside it. |
| `maxLength` | `number` |  | The most characters the text may hold; an edit that would pass it is refused. |
| `name` | `string` |  | Adds a field with this name holding the HTML, for a form that posts natively. |
| `onChange` | `((html: string) => void)` |  | Called with the HTML after every change (`""` once the editor is empty). |
| `onJsonChange` | `((json: JSONContent) => void)` |  | Called with the content as Tiptap JSON after every change. |
| `placeholder` | `string` |  | The hint shown while the editor is empty. |
| `readOnly` | `boolean` | `false` | Shows the content without a toolbar; it can be selected and copied but not changed. |
| `size` | `"default" \| "sm" \| "lg"` |  | The text size, padding and toolbar: 12, 14 or 16 px text. Defaults to the provider's `controlSize`. |
| `toolbar` | `false \| EditorTool[][]` | `[   ["heading"],   ["bold", "italic", "underline", "strike", "code"],   ["bulletList", "orderedList", "blockquote"],   ["link"],   ["undo", "redo"], ]` | The toolbar's controls, one inner list per group; `false` hides it. |
| `value` | `string` |  | The content as HTML, controlled. An empty editor's value is `""`. |
| `variant` | `"default" \| "filled"` |  | `filled` draws the grey `--field-filled` fill. Defaults to the provider's `fieldVariant`. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="editor-heading"` (Editor), and `data-active`, `data-tool`, `data-size`, `data-variant`, `data-disabled`, `data-readonly`, `data-invalid`, `data-full`.

**Provider strings:** `bold`, `italic`, `underline`, `strikethrough`, `inlineCode`, `bulletList`, `orderedList`, `blockquote`, `textStyle`, `paragraph`, `heading`, `link`, `linkUrl`, `invalidLink`, `removeLink`, `apply`, `undo`, `redo`, `editorToolbar`, `characterCount`, `characters` (`BooleanUIProvider`'s `strings`).

## Theming

The frame has the field look: `--field` (`--field-filled` when filled, `--field-disabled` when disabled), the `--control` edge, `--control-hover` on hover, `--ring` while the text has focus, `--invalid` when invalid. The toolbar is ruled off with `--border`; its icons are `--muted-foreground`, `--foreground` on hover and `--primary` on `--accent` when pressed. Quotes have a `--control` bar, inline code and code blocks a `--muted` fill, links `--primary`; the placeholder and the counter are `--muted-foreground`.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--border` | border |
| `--control` | border |
| `--control-hover` | border |
| `--destructive-strong` | text |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--field-filled` | background |
| `--foreground` | text |
| `--invalid` | border |
| `--muted` | background |
| `--muted-foreground` | text |
| `--primary` | text |
| `--ring` | outline, border |
