ComponentsForm
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: pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit
Usage
The editor is built on Tiptap 3, whose packages load only with this entry.
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.
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.
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.
Read only
readOnly shows release notes without a toolbar; the text can be selected and copied.
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.
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.
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.
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.
Sizes
size sm, default and lg: 12, 14 and 16 px text, with the toolbar scaled to match.
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.
With a form
name submits the HTML; a disabled editor submits nothing, as a disabled field does; the form's Reset puts defaultValue back.
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
divwithrole="textbox"andaria-multiline="true", carrying the name,aria-describedby,aria-invalid,aria-readonly,aria-disabledandaria-placeholder. The toolbar is arole="toolbar"that names the text it controls (aria-controls); format buttons are toggle buttons witharia-pressedand their shortcut inaria-keyshortcuts; the text style is a select (role="combobox"). The link form is a dialog with a labelled field; a refused address setsaria-invalidand describes the field with the message. - Labels
- Name the editor with
aria-labeloraria-labelledby. The toolbar is named byeditorToolbar, the select bytextStyle(its optionsparagraphandheading, "Heading {level}"), the buttons bybold,italic,underline,strikethrough,inlineCode,bulletList,orderedList,blockquote,link,undoandredo. The link form useslinkUrl,invalidLink,applyandremoveLink; the countercharacterCount("{count} of {max} characters") orcharacters. - 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
--ringwhile 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
maxLengthis 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. |
| HomeorEnd | In the toolbar, moves to the first or last control. |
| EnterorSpace | On a format button, turns the format on or off, keeping focus on the button; on the text style, opens its list. |
| CtrlB | Bold (โB on a Mac). |
| CtrlI | Italic (โI on a Mac). |
| CtrlU | Underline (โU on a Mac). |
| CtrlShiftS | Strikethrough (โโงS on a Mac). |
| CtrlE | Inline code (โE on a Mac). |
| CtrlAlt1โ3 | Heading 1, 2 or 3 (โโฅ1โ3 on a Mac). |
| CtrlAlt0 | Back to a paragraph (โโฅ0 on a Mac). |
| CtrlShift8 | Bulleted list (โโง8 on a Mac). |
| CtrlShift7 | Numbered list (โโง7 on a Mac). |
| CtrlShiftB | Quote (โโงB on a Mac). |
| CtrlK | Opens the link form for the selection (โK on a Mac). |
| CtrlZ | Undo (โZ on a Mac). |
| CtrlShiftZ | 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.
The tokens its classes read. Change their values in your theme, and it follows.
| 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 |