Skip to content
kerfjsGitHub

RichTextEditor

Formatted text with a toolbar, Markdown shortcuts and / for blocks.

SourcePackage @kerf-js/veneer

Preview

tsx
import { RichTextEditor } from '@kerf-js/veneer'export function Basic() {  return (  <RichTextEditor    label="Release notes"    defaultValue={NOTES}    mentions={people}  />  )}const PEOPLE = [  {    id: 'mara',    name: 'Mara Lindqvist',    email: 'mara@kerf.dev',    role: 'Owner',    team: 'Platform',    initials: 'ML',  },  {    id: 'jun',    name: 'Jun Tanaka',    email: 'jun@kerf.dev',    role: 'Admin',    team: 'Platform',    initials: 'JT',  },  {    id: 'priya',    name: 'Priya Raman',    email: 'priya@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'PR',  },  {    id: 'tomas',    name: 'Tomás Oliveira',    email: 'tomas@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'TO',  },  {    id: 'lena',    name: 'Lena Fischer',    email: 'lena@kerf.dev',    role: 'Member',    team: 'Data',    initials: 'LF',  },  {    id: 'noor',    name: 'Noor Haddad',    email: 'noor@kerf.dev',    role: 'Viewer',    team: 'Data',    initials: 'NH',  },  {    id: 'sam',    name: 'Sam Okafor',    email: 'sam@kerf.dev',    role: 'Member',    team: 'Mobile',    initials: 'SO',  },  {    id: 'ines',    name: 'Inés Vidal',    email: 'ines@kerf.dev',    role: 'Viewer',    team: 'Mobile',    initials: 'IV',  },]const MEMBERS = PEOPLE.map(  ({ id, name, team }) => ({    id,    label: name,    meta: 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 @kerf-js/veneer

Usage

tsx
import { RichTextEditor } from '@kerf-js/veneer'<RichTextEditor  label="Release notes"  defaultValue={NOTES}  mentions={people}/>

A bubble toolbar

toolbar="bubble" shows it over a selection.

tsx
import { RichTextEditor } from '@kerf-js/veneer'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 '@kerf-js/veneer'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 '@kerf-js/core'import {  CodeBlock,  RichTextEditor,  Stack} from '@kerf-js/veneer'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 '@kerf-js/veneer'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@kerf.dev',    role: 'Owner',    team: 'Platform',    initials: 'ML',  },  {    id: 'jun',    name: 'Jun Tanaka',    email: 'jun@kerf.dev',    role: 'Admin',    team: 'Platform',    initials: 'JT',  },  {    id: 'priya',    name: 'Priya Raman',    email: 'priya@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'PR',  },  {    id: 'tomas',    name: 'Tomás Oliveira',    email: 'tomas@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'TO',  },  {    id: 'lena',    name: 'Lena Fischer',    email: 'lena@kerf.dev',    role: 'Member',    team: 'Data',    initials: 'LF',  },  {    id: 'noor',    name: 'Noor Haddad',    email: 'noor@kerf.dev',    role: 'Viewer',    team: 'Data',    initials: 'NH',  },  {    id: 'sam',    name: 'Sam Okafor',    email: 'sam@kerf.dev',    role: 'Member',    team: 'Mobile',    initials: 'SO',  },  {    id: 'ines',    name: 'Inés Vidal',    email: 'ines@kerf.dev',    role: 'Viewer',    team: 'Mobile',    initials: 'IV',  },]const Cell = ({  name,  children,}: {  name: string  children?: unknown}) => (  <Stack gap="tight">    <Text      role="caption"      subtle    >      {name}    </Text>    {children}  </Stack>)const MEMBERS = PEOPLE.map(  ({ id, name, team }) => ({    id,    label: name,    meta: 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

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

PropTypeDefaultDescription
labelstring—The visible name, and what screen readers announce.
hintunknown—A short line of help under the field.
errorunknown—Replaces the hint and marks the field invalid.
iconIconRef—Leading icon: it sweeps with the border.
prefixunknown—Text or an element inside the field before the value, such as $.
suffixunknown—Text or an element inside the field after the value, such as kg.
variant"floating" | "classic"—floating (default, or the app's veneer({ 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.
clearablebooleanfalseA clear button (shown once there is a value).
counternumber—Maximum length, shown as a counter.
idstring—The element id. One is made for you if you leave it out.
disabledbooleanfalseCan't be focused or changed.
readonlybooleanfalseShows the value and lets it be selected and copied, but not changed.
valuestring | State<string>—The current value, when you own the state.
defaultValuestring—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.
marksMark[]['bold', 'italic', 'strike', 'code', 'link']Which inline tools to show.
blocksBlock[]['h2', 'ul', 'ol', 'quote']Which block tools to show.
commandsSlashCommand[]SLASHWhat / offers.
mentions{ search: (q: string, signal: AbortSignal) => Promise<MentionItem[]>; }—Turns on @: { search }.
placeholderstring'Write, or type / for blocks…'Shown while it's empty.
minRowsnumber—The least height in lines.
maxRowsnumber—The most height before it scrolls.
namestring—Posts the value with a form.
EventTypeDefaultDescription
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.

RelatedMarkdown editorMentions inputTextarea