RichTextEditor
Formatted text with a toolbar, Markdown shortcuts and / for blocks.
SourcePackage @weft-js/warp
Preview
tsx
import { RichTextEditor } from '@weft-js/warp'export function Basic() { return ( <RichTextEditor label="Release notes" defaultValue={NOTES} mentions={people} /> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' }, { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' }, { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' }, { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' }, { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' }, { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.dev', role: 'Viewer', team: 'Mobile', initials: 'IV' },]const MEMBERS = PEOPLE.map(p => ({ id: p.id, label: p.name, meta: p.team }))const matching = (list: typeof MEMBERS, q: string) => list.filter(i => i.label.toLowerCase().includes(q.toLowerCase())).slice(0, 5)const NOTES = '<h2>October release</h2><p>Product images now come from the <strong>edge</strong>, about 40% faster.</p><ul><li>Apple Pay on the last checkout step</li><li>A shorter stale window</li></ul>'const people = { search: (q: string) => Promise.resolve(matching(MEMBERS, q)) }Installation
npm install @weft-js/warpUsage
tsx
import { RichTextEditor } from '@weft-js/warp'<RichTextEditor label="Release notes" defaultValue={NOTES} mentions={people} />A bubble toolbar
toolbar="bubble" shows it over a selection.
tsx
import { RichTextEditor } from '@weft-js/warp'export function Bubble() { return ( <RichTextEditor label="Release notes" toolbar="bubble" defaultValue="<p>Select some of this text to see the toolbar.</p>" /> )}Fewer tools
marks and blocks choose the buttons.
tsx
import { RichTextEditor } from '@weft-js/warp'export function Limited() { return ( <RichTextEditor label="Comment" marks={['bold', 'italic', 'link']} blocks={['ul']} placeholder="Leave a comment" /> )}Store Markdown
format="markdown" reads and writes Markdown. People still see formatted text.
tsx
import { state } from '@weft-js/core'import { CodeBlock, RichTextEditor, Stack } from '@weft-js/warp'export function Markdown() { const text = state(NOTES_MD) return ( <Stack gap="related"> <RichTextEditor label="Release notes" format="markdown" value={text} /> <CodeBlock code={text.value} language="markdown" /> </Stack> )}const NOTES_MD = '## October release\n\nProduct images now come from the **edge**.\n\n- Apple Pay at checkout\n- A shorter stale window\n'States
A rich text editor can start empty or filled, and can show a hint or an error.
tsx
import { Grid, RichTextEditor, Stack, Text } from '@weft-js/warp'export function States() { return ( <Grid cols={3} gap="group"> <Cell name="Empty"><RichTextEditor label="Notes" toolbar="none" /></Cell> <Cell name="Filled"><RichTextEditor label="Notes" toolbar="none" defaultValue="<p>Shipped on <strong>Friday</strong>.</p>" /></Cell> <Cell name="Hint"><RichTextEditor label="Notes" toolbar="none" hint="Type / for blocks, @ to tag someone." mentions={people} /></Cell> <Cell name="Invalid"><RichTextEditor label="Notes" toolbar="none" error="Write something before you publish." /></Cell> </Grid> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' }, { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' }, { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' }, { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' }, { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' }, { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.dev', role: 'Viewer', team: 'Mobile', initials: 'IV' },]const Cell = (p: { name: string; children?: unknown }) => ( <Stack gap="tight"> <Text role="caption" subtle>{p.name}</Text> {p.children} </Stack>)const MEMBERS = PEOPLE.map(p => ({ id: p.id, label: p.name, meta: p.team }))const matching = (list: typeof MEMBERS, q: string) => list.filter(i => i.label.toLowerCase().includes(q.toLowerCase())).slice(0, 5)const people = { search: (q: string) => Promise.resolve(matching(MEMBERS, q)) }When to use
Use it for
- Long notes, articles, descriptions with structure
Reach for something else
- MarkdownEditor, when people prefer to see the source
- Textarea, when plain text will do
Accessibility
- ⌘B
- Bold
- ⌘I
- Italic
- ⌘K
- Link
- /
- Opens the block list on an empty line
- ←→
- Moves along the toolbar
- The toolbar is one Tab stop, with arrow keys inside it.
- Everything that enters the editor, a value or a paste, is cleaned first.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The visible name, and what screen readers announce. |
hint | unknown | — | A short line of help under the field. |
error | unknown | — | Replaces the hint and marks the field invalid. |
icon | IconRef | — | Leading icon: it sweeps with the border. |
prefix | unknown | — | Text or an element inside the field before the value, such as $. |
suffix | unknown | — | Text or an element inside the field after the value, such as kg. |
variant | "floating" | "classic" | — | floating (default, or the app's warp({ fields })) · classic: the label above. |
appearance | "line" | — | line: only a bottom line under the value, no box or fill. The boxed field otherwise. |
size | "sm" | "md" | "lg" | 'md' | Control size. md by default. |
clearable | boolean | false | A clear button (shown once there is a value). |
counter | number | — | Maximum length, shown as a counter. |
id | string | — | The element id. One is made for you if you leave it out. |
disabled | boolean | false | Can't be focused or changed. |
readonly | boolean | false | Shows the value and lets it be selected and copied, but not changed. |
value | string | State<string> | — | The current value, when you own the state. |
defaultValue | string | — | The starting value, when the component owns the state. |
format | "html" | "markdown" | 'html' | html or markdown, for what value holds. |
toolbar | "none" | "fixed" | "bubble" | 'fixed' | fixed, bubble or none. A phone docks it above the keyboard. |
marks | Mark[] | ['bold', 'italic', 'strike', 'code', 'link'] | Which inline tools to show. |
blocks | Block[] | ['h2', 'ul', 'ol', 'quote'] | Which block tools to show. |
commands | SlashCommand[] | SLASH | What / offers. |
mentions | { search: (q: string, signal: AbortSignal) => Promise<MentionItem[]>; } | — | Turns on @: { search }. |
placeholder | string | 'Write, or type / for blocks…' | Shown while it's empty. |
minRows | number | — | The least height in lines. |
maxRows | number | — | The most height before it scrolls. |
name | string | — | Posts the value with a form. |
| Event | Type | Default | Description |
|---|---|---|---|
onInput | ((e: InputEvent & { currentTarget: HTMLInputElement & HTMLTextAreaElemen… | — | Called on each keystroke. |
onValueChange | ((v: string, reason: ChangeReason) => void) | — | Called with each new value, and whether a pointer, a key or code made the change. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="rte"] [data-part="tool"]
toolframeseplinkbarsr-onlycontentcaret-anchorsweeplistboxopt-meta