<!-- Protocore Design System — RichTextEditor -->
# RichTextEditor

- **Category:** Inputs (`inputs`)
- **Slug:** `inputs/rich-text-editor`
- **Status:** stable
- **Platforms:** web
- **Import:** `import { RichTextEditor } from "@protocore/pds";`
- **Docs:** https://pds.protocore.io/components/inputs/rich-text-editor

> A bordered, sunken WYSIWYG surface with a mono formatting toolbar, built on tiptap. Controlled via value/onChange.

## Optional peer dependencies

RichTextEditor ships on the **`@protocore/pds/editor`** subpath so its editor engine stays out of the main bundle — exactly like charts keep recharts out. It requires the **optional** `tiptap` peers, installed alongside `@protocore/pds`:

`npm i @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder`

Import it from the subpath: `import { RichTextEditor } from "@protocore/pds/editor";`

## When to use it

Reach for **RichTextEditor** when authors need *formatted* prose — headings, lists, links, emphasis — such as CMS body content, changelog entries, or support replies. For **plain, unformatted** multi-line text (a note, a description, an address) use **Textarea**: it is lighter, has no peer dependencies, and posts a simple string. For read-only display of code, use **CodeBlock**.

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `value` | `string` | no | — | Controlled document value. HTML by default; a stringified ProseMirror JSON when `format="json"`. Pair with `onChange`. |
| `defaultValue` | `string` | no | — | Uncontrolled initial value, in the same serialization as `value`. |
| `onChange` | `(value: string) => void` | no | — | Fired with the serialized document on every edit (HTML, or JSON when `format="json"`). |
| `format` | `"html" \| "json"` | no | `"html"` | Serialization used for `value` / `onChange`. |
| `placeholder` | `string` | no | — | Muted placeholder shown while the document is empty. |
| `readOnly` | `boolean` | no | `false` | Render read-only — the toolbar disables and the surface is not editable. |
| `toolbar` | `boolean` | no | `true` | Show the formatting toolbar. |
| `minHeight` | `number` | no | `180` | Minimum height of the writing surface, in px. |
| `aria-label` | `string` | no | `"Rich text editor"` | Accessible name for the editable region. |
| `editorRef` | `(editor: Editor \| null) => void` | no | — | Escape hatch — receives the underlying tiptap `Editor` instance once ready (or `null`). |

## Examples

### Controlled editor

Pass `value` + `onChange` to control the document. The toolbar toggles **bold**, *italic*, strike, inline code, a heading, bullet / numbered lists, blockquote, and links; the active mark inverts to the solid fill. All the usual keyboard shortcuts (`⌘B`, `⌘I`, …) come from tiptap.

```tsx
import { useState } from "react";
import { RichTextEditor } from "@protocore/pds/editor";

const INITIAL = `
<h2>Release notes — v1.7.0</h2>
<p>This drop wires the <strong>publish → rebuild</strong> webhook for the
<code>trasor</code> and <code>roxana</code> tenants.</p>
<ul>
  <li>Static builds now redeploy within ~90s of a CMS publish.</li>
  <li>Media URLs resolve through the CloudFront edge, not the origin.</li>
</ul>
<blockquote>Heads up: draft entries stay out of the sitemap.</blockquote>
`;

export default function Demo() {
  const [html, setHtml] = useState(INITIAL);
  return (
    <RichTextEditor
      aria-label="Release notes"
      value={html}
      onChange={setHtml}
      placeholder="Write the release notes…"
    />
  );
}
```

### Comment composer

A compact editor with a lower `minHeight`, wired to a submit row. Strip the tags to detect an empty document before enabling the action.

```tsx
import { useState } from "react";
import { Button, Stack } from "@protocore/pds";
import { RichTextEditor } from "@protocore/pds/editor";

export default function Demo() {
  const [html, setHtml] = useState("");
  const empty = html.replace(/<[^>]*>/g, "").trim().length === 0;

  return (
    <Stack gap={3} style={{ maxWidth: 520 }}>
      <RichTextEditor
        aria-label="Reply to ticket PC-4821"
        value={html}
        onChange={setHtml}
        placeholder="Reply to the customer…"
        minHeight={120}
      />
      <Stack direction="row" gap={2} justify="end">
        <Button variant="ghost" onClick={() => setHtml("")} disabled={empty}>
          Discard
        </Button>
        <Button variant="primary" disabled={empty}>
          Send reply
        </Button>
      </Stack>
    </Stack>
  );
}
```

## Do & don't

**Do**

- Install the optional `@tiptap/*` peers, and import from `@protocore/pds/editor`.
- Give the editor an accessible name via `aria-label` (or an associated `Field` label).
- Keep the document controlled with `value` / `onChange`, or uncontrolled with `defaultValue` — not a mix.
- Persist and re-hydrate with a single serialization — HTML *or* JSON via `format` — consistently.

**Don't**

- Don't import RichTextEditor from the main `@protocore/pds` entry — it lives on the `/editor` subpath.
- Don't use it for plain text — a Textarea is the right, dependency-free control.
- Don't feed `value` a string in a different `format` than you configured — the editor can't parse it.
- Don't rely on it rendering without the peers installed; the subpath is intentionally dependency-gated.

## Accessibility

**Keyboard**

| Keys | Action |
| --- | --- |
| `⌘/Ctrl + B` | Toggle bold on the selection. |
| `⌘/Ctrl + I` | Toggle italic on the selection. |
| `Tab` | Move focus from the surface out of the editor (does not insert a tab). |
| `Enter` | New paragraph; inside a list, a new list item. |
| `Backspace at line start` | Lift a list item / blockquote back to a paragraph. |

**Notes**

- The writing surface exposes `role="textbox"` with `aria-multiline="true"` and the `aria-label` you provide.
- Every toolbar button is icon-only with an `aria-label`, sits in a `role="toolbar"` group, and carries `aria-pressed` reflecting whether its mark/node is active at the cursor.
- In `readOnly` mode the toolbar buttons are `disabled` and the surface is not editable.
- Toolbar buttons preventDefault on mousedown so clicking one keeps the text selection, applying the command to the intended range.

## Related

`textarea`, `input`, `code-block`, `field`

---

© Protocore. All rights reserved. Use of the Protocore Design System requires prior written authorization from Protocore (contact@protocore.io). These machine-readable materials must not be ingested into ML-training datasets or derivative design systems. See https://pds.protocore.io/legal/
