busy-office-ui
Menu

Rich text

Formatted text in a form — delivery notes, approval comments, return instructions: a field-shaped container with a formatting toolbar and a content area, plus .bo-prose for displaying stored rich content anywhere. The framework ships the chrome, not an editing engine — see "Why no engine" below. Not for an ordinary note field — most ERP free-text is a plain textarea.bo-input (one class, already styled), and reaching for rich text commits you to sanitizing, storing and printing HTML forever. Step up only when someone genuinely needs bold, lists or links in the stored value; to display a value that is already rich, use prose, not a disabled editor.

Basic — the recommended ERP set

Pallet 2 arrived damp on one corner — photos attached to the receipt. Count verified against the ASN.

View code
<div class="bo-richtext">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <div class="bo-richtext__group" role="group" aria-label="Text style">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
              title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="italic" aria-pressed="false"
              title="Italic (Ctrl/Cmd + I)" aria-keyshortcuts="Control+I Meta+I"><em>I</em></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Lists">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertUnorderedList"
              aria-label="Bulleted list" title="Bulleted list"><span class="bo-icon bo-icon--list-bullet" aria-hidden="true"></span></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertOrderedList"
              aria-label="Numbered list" title="Numbered list"><span class="bo-icon bo-icon--list-numbered" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Insert">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="createLink"
              aria-label="Link" title="Link"><span class="bo-icon bo-icon--link" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Help">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-dialog-trigger="rt-keys"
              aria-label="Keyboard shortcuts" title="Keyboard shortcuts"><span class="bo-icon bo-icon--keyboard" aria-hidden="true"></span></button>
    </div>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="true"
       role="textbox" aria-multiline="true" aria-label="Delivery notes"
       data-placeholder="Add a note — what arrived, and in what condition.">
    <p>Pallet 2 arrived <strong>damp on one corner</strong> — photos attached
    to the receipt. Count verified against the ASN.</p>
  </div>
</div>

Five commands: bold, italic, both lists, link. That set covers every real ERP note — delivery notes, approval comments, return instructions, dunning text — and every one of the five survives a sanitizer allowlist, an export and a print form without argument. Everything past it is markup your downstream boundaries have to keep honouring, which is why the full native surface below is shown as a price list rather than a recommendation. Toolbar buttons are plain ghost buttons carrying data-richtext-cmd; the Markup section has the entire wiring, including the mousedown guard that keeps the selection alive. Try bold/italic/lists — or the keyboard shortcuts, which work without touching the toolbar at all.

Keyboard shortcuts — you already have them

Keyboard shortcuts

These come from the browser, not from this framework — they work whether or not the toolbar is showing.

Ctrl/Cmd + B
bold the selection
Ctrl/Cmd + I
italicise the selection
Ctrl/Cmd + U
underline the selection
Ctrl/Cmd + Z
undo, and Shift+Ctrl/Cmd+Z (or Ctrl+Y) to redo
Ctrl/Cmd + A
select the whole field, not the page
Tab (in a list)
indent, nesting the item under the one above; Shift+Tab outdents it again. This one is NOT native — it is the optional recipe on the docs page, scoped to list items on purpose
Tab (anywhere else)
moves to the next control, as in any form field. Indent outside a list produces a styled blockquote rather than an indent, so capturing Tab there would store a semantic lie
Escape, then Tab
leaves the field even from inside a list. The release is what keeps list-Tab from being a WCAG 2.1.2 keyboard trap: without it, a field whose content is entirely a list has no Tab exit at all
Alt + Shift + T
show or hide the formatting toolbar. Also a recipe key rather than a native one, and chosen to dodge the browser shortcuts Ctrl/Cmd+Shift+T and Alt+F10 already own
Enter
new paragraph; Shift+Enter for a line break inside one
View code
<dialog class="bo-dialog" id="rt-keys" aria-labelledby="rt-keys-title">
  <form method="dialog">
    <header class="bo-dialog__header">
      <h2 class="bo-dialog__title" id="rt-keys-title">Keyboard shortcuts</h2>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" value="cancel" aria-label="Close">✕</button>
    </header>
    <div class="bo-dialog__body">
      <p class="bo-u-text-muted">These come from the browser, not from this
      framework — they work whether or not the toolbar is showing.</p>
      <dl class="bo-kv">
        <div><dt><kbd class="bo-kbd">Ctrl/Cmd</kbd> + <kbd class="bo-kbd">B</kbd></dt><dd>bold the selection</dd></div>
        <div><dt><kbd class="bo-kbd">Ctrl/Cmd</kbd> + <kbd class="bo-kbd">I</kbd></dt><dd>italicise the selection</dd></div>
        <div><dt><kbd class="bo-kbd">Ctrl/Cmd</kbd> + <kbd class="bo-kbd">U</kbd></dt><dd>underline the selection</dd></div>
        <div><dt><kbd class="bo-kbd">Ctrl/Cmd</kbd> + <kbd class="bo-kbd">Z</kbd></dt><dd>undo, and Shift+Ctrl/Cmd+Z (or Ctrl+Y) to redo</dd></div>
        <div><dt><kbd class="bo-kbd">Ctrl/Cmd</kbd> + <kbd class="bo-kbd">A</kbd></dt><dd>select the whole field, not the page</dd></div>
        <div><dt><kbd class="bo-kbd">Tab (in a list)</kbd></dt><dd>indent, nesting the item under the one above; Shift+Tab outdents it again. This one is NOT native — it is the optional recipe on the docs page, scoped to list items on purpose</dd></div>
        <div><dt><kbd class="bo-kbd">Tab (anywhere else)</kbd></dt><dd>moves to the next control, as in any form field. Indent outside a list produces a styled blockquote rather than an indent, so capturing Tab there would store a semantic lie</dd></div>
        <div><dt><kbd class="bo-kbd">Escape, then Tab</kbd></dt><dd>leaves the field even from inside a list. The release is what keeps list-Tab from being a WCAG 2.1.2 keyboard trap: without it, a field whose content is entirely a list has no Tab exit at all</dd></div>
        <div><dt><kbd class="bo-kbd">Alt</kbd> + <kbd class="bo-kbd">Shift</kbd> + <kbd class="bo-kbd">T</kbd></dt><dd>show or hide the formatting toolbar. Also a recipe key rather than a native one, and chosen to dodge the browser shortcuts Ctrl/Cmd+Shift+T and Alt+F10 already own</dd></div>
        <div><dt><kbd class="bo-kbd">Enter</kbd></dt><dd>new paragraph; Shift+Enter for a line break inside one</dd></div>
      </dl>
    </div>
    <footer class="bo-dialog__footer">
      <button class="bo-btn" value="confirm">Close</button>
    </footer>
  </form>
</dialog>

Every shortcut here is native contenteditable, shipped by the browser with zero framework JS. Verified by driving real key events at the Basic demo: Ctrl/Cmd + B on a selection produced <b>…</b> with nothing of ours listening. Do not wire your own keydown for these — you would shadow the browser's and give up its edge cases (IME composition, undo grouping) for nothing. What DOES need wiring is the toolbar's aria-pressed, and it must follow selectionchange rather than click: a hot key fires no click, so a click-only sync leaves Bold reading "off" while the text is bold — wrong for exactly the users who depend on it. That was a real defect here until 137.2, and the same fix covers merely moving the caret into bold text. The buttons advertise their shortcut through title and aria-keyshortcuts, and the toolbar's keyboard button opens this map. The table is generated from the @keymap block in richtext.css, so it cannot drift from what the component documents.

Rich text keyboard shortcuts
KeyDoes
Ctrl/Cmd + B bold the selection
Ctrl/Cmd + I italicise the selection
Ctrl/Cmd + U underline the selection
Ctrl/Cmd + Z undo, and Shift+Ctrl/Cmd+Z (or Ctrl+Y) to redo
Ctrl/Cmd + A select the whole field, not the page
Tab (in a list) indent, nesting the item under the one above; Shift+Tab outdents it again. This one is NOT native — it is the optional recipe on the docs page, scoped to list items on purpose
Tab (anywhere else) moves to the next control, as in any form field. Indent outside a list produces a styled blockquote rather than an indent, so capturing Tab there would store a semantic lie
Escape, then Tab leaves the field even from inside a list. The release is what keeps list-Tab from being a WCAG 2.1.2 keyboard trap: without it, a field whose content is entirely a list has no Tab exit at all
Alt + Shift + T show or hide the formatting toolbar. Also a recipe key rather than a native one, and chosen to dodge the browser shortcuts Ctrl/Cmd+Shift+T and Alt+F10 already own
Enter new paragraph; Shift+Enter for a line break inside one

Keyboard-first lists — -, *, 1. and Tab

View code
<div class="bo-richtext">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertUnorderedList"
            aria-label="Bulleted list" title="Bulleted list"><span class="bo-icon bo-icon--list-bullet" aria-hidden="true"></span></button>
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertOrderedList"
            aria-label="Numbered list" title="Numbered list"><span class="bo-icon bo-icon--list-numbered" aria-hidden="true"></span></button>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="true" data-empty
       role="textbox" aria-multiline="true" aria-label="Return instructions"
       data-placeholder="Type &quot;- &quot;, &quot;1. &quot; or &quot;5. &quot; to start a list, then Tab to nest…"></div>
</div>

Type - , *  or 1.  at the start of a line and it becomes a real list; Tab nests the item, Shift + Tab lifts it back out. This is the affordance that lets a clerk build a list without ever reaching the toolbar — and it is the reason the recommended toolbar can stay at five buttons. None of it is native: contenteditable has no input rules, so typing -  otherwise leaves you the literal characters. The wiring is in the Markup section, about fifteen lines.

The number you type is the number the list starts at. 5.  begins at 5, 12)  at 12; 1.  is left plain because start="1" is the default and writing it would be noise in the stored value. execCommand gives you no say in this — insertOrderedList always starts at 1 and takes no argument — so the recipe sets the plain-HTML start attribute afterwards. To renumber partway down a list, that is <li value="9"> on the item itself. Both are real HTML that prints and exports; nothing here styles ol with CSS counters, which is the usual reason start silently does nothing in a design system.

Three things this page got wrong before testing them, kept here because you will hit all three. (1) The trailing space is not a space. contenteditable converts it to U+00A0 so it will not collapse, so a regex written against an ASCII space matches nothing and the rule silently never fires — the character codes here are [45, 160]. (2) Tab is scoped to list items on purpose. execCommand('indent') in a plain paragraph emits <blockquote style="margin:0 0 0 40px"> — a semantic lie and an inline style your sanitizer will strip, so the indent would vanish on save. Outside a list, Tab moves to the next control like any form field. (3) Escape releases the field. A field whose whole content is a list would otherwise have no Tab exit at all, which is a WCAG 2.1.2 keyboard trap; press Escape, then Tab, and focus leaves. That is in the keyboard map.

One caveat you cannot fix from here: Chrome's indent nests as <ul><li>a</li><ul>…</ul></ul>, putting the inner list as a sibling of the <li> rather than inside it — which is invalid HTML that parsers normalise differently. If nested lists genuinely matter to your stored value, that is a reason to mount a real engine rather than a reason to stop using Tab.

Hide the toolbar, keep the affordance

Customer called about the short-shipped hose assembly.

View code
<div class="bo-richtext">
  <div class="bo-richtext__toolbar-collapse" id="rt-collapse-bar">
    <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
            title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="italic" aria-pressed="false"
            title="Italic (Ctrl/Cmd + I)" aria-keyshortcuts="Control+I Meta+I"><em>I</em></button>
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertUnorderedList"
              aria-label="Bulleted list" title="Bulleted list"><span class="bo-icon bo-icon--list-bullet" aria-hidden="true"></span></button>
    </div>
  </div>
  <button class="bo-btn bo-btn--ghost bo-btn--icon bo-richtext__toolbar-toggle" type="button"
          data-richtext-toggle aria-expanded="true" aria-controls="rt-collapse-bar"
          aria-label="Hide formatting toolbar (Alt + Shift + T)"
          title="Hide formatting toolbar (Alt + Shift + T)"
          aria-keyshortcuts="Alt+Shift+T"><span class="bo-icon bo-icon--format" aria-hidden="true"></span></button>
  <div class="bo-richtext__content bo-prose" contenteditable="true"
       role="textbox" aria-multiline="true" aria-label="Internal note"
       data-placeholder="Internal note…">
    <p>Customer called about the short-shipped hose assembly.</p>
  </div>
</div>

A dense form with several note fields carries several full toolbars, which is mostly noise. Collapsing removes the whole bar — the first version of this hid only the buttons and left the strip at full height, which saved nothing on screen — and leaves a single control floating over the field's top corner. The toggle always stays, so the field still says it is rich. That is the same argument this framework uses to refuse a floating selection toolbar that appears only once text is highlighted: in a form, chrome that appears only once you select something hides the fact that the field takes formatting at all. A document can afford that; a field a user meets for eight seconds cannot.

The icon does not change between states, and that is deliberate. A glyph that flips + / − asks the reader to decode a direction, and + in an ERP reads as "add a row" rather than "show formatting". A formatting glyph just names what is behind the button — the settled convention in mainstream composers for this exact control. State rides on aria-expanded, on the bar being there or not, and on the button's own pressed styling. Alt + Shift + T toggles it from inside the field (chosen to dodge Ctrl/Cmd + Shift + T and Alt + F10, which the browser and other editors already own), and it is in the keyboard map. Every shortcut keeps working while the toolbar is hidden — which is rather the point.

There are two kinds of grouping, and the difference is whether the choice is exclusive. Buttons that are merely the same category — bold/italic/strike, or the two list buttons — stay separate toggles inside a .bo-richtext__group: each is independently on or off, and a block can be neither bulleted nor numbered. Buttons that are one choice of several — alignment — are a segmented control instead, which is a real radio group. That is not cosmetic: three aria-pressed toggles announce "three independent buttons" when the truth is "one of three", and the exclusivity then has to be maintained by hand in JS. As radios the platform owns it — group membership, arrow-key navigation and "1 of 3" all come free, and nothing clears the other two.

Motion is opt-in and costs one wrapper — no JS and no second attribute. Put the toolbar inside .bo-richtext__toolbar-collapse and it rolls up instead of vanishing, driven straight off the toggle's own aria-expanded. It animates real auto height via the grid-template-rows: 0fr/1fr technique, so there is no measured pixel and no magic maximum, and the duration comes from the tokens — which are 0ms under prefers-reduced-motion, so honouring that needs no extra code. An unwrapped toolbar keeps hiding instantly: the instant rule targets a direct child, so opting in overrides nothing. Why not the .bo-motion-collapse utility? Because motion.css is deliberately opt-in and never imported by index.css — the first attempt here shipped that class and animated nothing at all, since its rules were in a stylesheet the page never loaded.

Icon-only buttons take .bo-btn--icon. Without it an icon button is padded like a text button — measured here at 47px wide around a 13px glyph before this was fixed, against a 4px gap. The gap was never the problem; the button was. With the modifier they are square and track density (28px at compact, 44px at spacious), and stay above the 24px target floor.

Group buttons that belong together. .bo-richtext__group is a real role="group" with its own aria-label, so the categories a sighted user reads from the separator lines exist for a screen-reader user too — the decorative __divider span never did that, which made it a single-channel cue in a framework that refuses those. Groups also wrap as units: before this, the Advanced toolbar wrapped mid-category and orphaned the two list buttons on a row of their own.

Empty, and invalid

View code
<div class="bo-richtext">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
            title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="italic" aria-pressed="false"
            title="Italic (Ctrl/Cmd + I)" aria-keyshortcuts="Control+I Meta+I"><em>I</em></button>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="true" data-empty
       role="textbox" aria-multiline="true" aria-label="Rejection reason"
       data-placeholder="Why is this being rejected?"></div>
</div>

A reason is required to reject.

View code
<div class="bo-form-field">
  <label class="bo-form-field__label" for="rt-reason">Rejection reason</label>
  <div class="bo-richtext">
    <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
              title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
    </div>
    <div class="bo-richtext__content bo-prose" contenteditable="true" id="rt-reason" data-empty
         role="textbox" aria-multiline="true" aria-label="Rejection reason"
         aria-invalid="true" aria-describedby="rt-reason-err"
         data-placeholder="Why is this being rejected?"></div>
  </div>
  <p class="bo-form-field__message" id="rt-reason-err">A reason is required to reject.</p>
</div>

A placeholder needs one line of your JS, and the reason is worth knowing. contenteditable has no placeholder attribute, and CSS cannot supply one: :empty::before works exactly once — it shows on a pristine field, then the first type-then-delete leaves a <br> behind, :empty stops matching, and the hint never returns. <p></p>, which execCommand produces, never matched at all. Emptiness is a property of text content and selectors cannot read text content — so the framework styles [data-empty]::before and you toggle the attribute on input. Invalid is now on the field itself: until 136.1 .bo-richtext was the only control in the form family whose border never reddened, so on a form scanned for red boxes it was the box that never turned red. Pair aria-invalid with an aria-describedby message, as shown.

Advanced — everything native gives you, and why you want less

Restock only if the seal is unbroken. Otherwise route to Quality — see the exception process linked below.

View code
<div class="bo-richtext">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <div class="bo-richtext__group" role="group" aria-label="Text style">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
            title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="italic" aria-pressed="false"
            title="Italic (Ctrl/Cmd + I)" aria-keyshortcuts="Control+I Meta+I"><em>I</em></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="strikeThrough" aria-pressed="false"
            title="Strikethrough"><s>S</s></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="removeFormat"
            aria-label="Clear formatting" title="Clear formatting"><span class="bo-icon bo-icon--clear-format" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Block format">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="formatBlock" data-richtext-value="H2">H2</button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="formatBlock" data-richtext-value="H3">H3</button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="formatBlock" data-richtext-value="BLOCKQUOTE"
            aria-label="Blockquote" title="Blockquote"><span class="bo-icon bo-icon--quote" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Alignment">
      <div class="bo-segmented">
        <input class="bo-segmented__input bo-visually-hidden" type="radio" name="rt-align" id="rt-a-left"
                 data-richtext-cmd="justifyLeft" checked>
        <label class="bo-segmented__option" for="rt-a-left" title="Align left"><span class="bo-icon bo-icon--align-left" aria-hidden="true"></span><span class="bo-visually-hidden">Align left</span></label>
        <input class="bo-segmented__input bo-visually-hidden" type="radio" name="rt-align" id="rt-a-center"
                 data-richtext-cmd="justifyCenter">
        <label class="bo-segmented__option" for="rt-a-center" title="Align centre"><span class="bo-icon bo-icon--align-center" aria-hidden="true"></span><span class="bo-visually-hidden">Align centre</span></label>
        <input class="bo-segmented__input bo-visually-hidden" type="radio" name="rt-align" id="rt-a-right"
                 data-richtext-cmd="justifyRight">
        <label class="bo-segmented__option" for="rt-a-right" title="Align right"><span class="bo-icon bo-icon--align-right" aria-hidden="true"></span><span class="bo-visually-hidden">Align right</span></label>
      </div>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Indent">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="outdent"
            aria-label="Decrease indent" title="Decrease indent"><span class="bo-icon bo-icon--outdent" aria-hidden="true"></span></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="indent"
            aria-label="Increase indent" title="Increase indent"><span class="bo-icon bo-icon--indent" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Lists">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertUnorderedList"
            aria-label="Bulleted list" title="Bulleted list"><span class="bo-icon bo-icon--list-bullet" aria-hidden="true"></span></button>
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertOrderedList"
            aria-label="Numbered list" title="Numbered list"><span class="bo-icon bo-icon--list-numbered" aria-hidden="true"></span></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Insert">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="createLink"
            aria-label="Link" title="Link"><span class="bo-icon bo-icon--link" aria-hidden="true"></span></button>
    </div>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="true"
       role="textbox" aria-multiline="true" aria-label="Return instructions">
    <p>Restock only if the seal is unbroken. Otherwise route to Quality —
    see the exception process linked below.</p>
  </div>
</div>

Read this as a price list, not a recommendation. Alignment and indentation in a stored ERP note are exactly the cost this page's opener warns about: markup that must now survive the sanitizer, the export and the print form, spent on centring text nobody asked to centre. Ship the five-command set unless someone can name the screen that needs more.

Still zero framework JS: every button here is execCommand/queryCommandState, the exact pattern Basic uses, extended two ways. Value-taking commands — H2/H3/quote read the tag from data-richtext-value and call execCommand('formatBlock', false, tag); Link prompts for a URL and calls createLink with it (Cancel leaves the selection untouched). A mutually-exclusive group — the three align buttons sync aria-pressed across all three after every click, not just the one pressed, so at most one ever reads "on" (verified live: justifyCenter correctly clears Left's and Right's pressed state, not just sets its own). Clear does less than it sounds like — tested live, not assumed: removeFormat strips inline styling (bold/italic/alignment) but leaves block elements and links alone — a heading stays a heading and a link stays clickable after Clear. Removing those needs formatBlock('P')/unlink explicitly, which this demo does not wire, matching how little "clear formatting" actually promises.

Read-only and disabled

Approved with one exception: line 3 ships from the Hamburg warehouse instead.

View code
<div class="bo-richtext bo-richtext--readonly">
  <div class="bo-richtext__content bo-prose" contenteditable="false" tabindex="0"
       role="textbox" aria-multiline="true" aria-readonly="true" aria-label="Approval note">
    <p>Approved with <em>one exception</em>: line 3 ships from the Hamburg
    warehouse instead.</p>
  </div>
</div>

Posting period closed — notes are locked.

View code
<div class="bo-richtext bo-richtext--disabled">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" disabled><strong>B</strong></button>
    <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" disabled><em>I</em></button>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="false"
       role="textbox" aria-multiline="true" aria-disabled="true" aria-label="Notes (locked)">
    <p>Posting period closed — notes are locked.</p>
  </div>
</div>

Read-only mutes the field and hides the toolbar but keeps normal text (selectable, copyable); disabled mutes text too. Both are two-channel: the visible chrome plus contenteditable="false" with aria-readonly/aria-disabled in the markup — the modifier class alone is only the visible half. Read-only content also needs tabindex="0" (shown above): unlike a native readonly input, a non-editable div drops out of the tab order, and a note longer than the box must stay keyboard-scrollable. Disabled toolbars need disabled on the buttons themselves — the modifier's pointer-events only stops the mouse, not Tab/Enter.

Displaying stored content

Rendering a STORED note (record detail, comments, print) needs none of this chrome — that is the standalone Prose component: one class on a wrapper, native elements inside. Put the same class on .bo-richtext__content so content looks identical while being edited and when displayed.

Why no engine — and how to mount a real one

Undo stacks, HTML sanitization, and selection models are dedicated-library territory; shipping a half-engine would be the least-simple surface in this framework (the same reasoning as the htmx stance: ready, not coupled). The light case above is genuinely native — execCommand plus contenteditable, zero framework JS. Know its two caveats: execCommand is deprecated (it ships in every engine and is not going anywhere soon, but it will never be extended), and its output HTML varies by engine — bold may come back as <b>, <strong>, or a styled span, strikethrough as <strike> (verified live in this browser — the deprecated tag, not <s>), and list nesting differs — so write your server-side sanitizer allowlist against all the variants, not the one your dev browser produces. When notes outgrow bold/italic/lists (or those caveats bite), mount a real editor inside the chrome; the shape for ProseMirror-style libraries:

import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
// keep the framework chrome; hand the CONTENT AREA to the engine
const host = document.querySelector(".bo-richtext__content");
host.removeAttribute("contenteditable");   // the engine manages its own
host.removeAttribute("role");              // engine content is structured —
                                           // role="textbox" would flatten it for AT
const view = new EditorView(host, {
  state: EditorState.create({ schema /* your schema + plugins */ }),
});
// your toolbar buttons dispatch the engine's commands instead of execCommand
// SANITIZE ON THE SERVER regardless of editor — stored HTML is untrusted input.

Four things this framework will never wire — bring your own engine

The reference screenshot behind this page's own wishlist (roadmap 113) showed four more toolbar buttons than execCommand can honestly support. Each is refused as new framework surface for the same reason the engine itself is refused (101.7's PDF/barcode precedent) — it is infrastructure or a distinct component, not a CSS framework's job — but a reader reaching for one deserves to be routed to the rung-4 recipe above, not left thinking rung 3 has a gap:

  1. Image upload — needs a server endpoint to receive the file and a URL to reference it; execCommand ('insertImage', false, url) only inserts an already-hosted URL. Your upload flow (drag-drop, progress, the endpoint) is application code either way; a mounted rung-4 engine's image plugin still calls back into it.
  2. Table insertion — needs real table-building logic (insert/delete row and column, merge, keyboard navigation between cells) that no browser command provides. This is exactly the "half-engine" this page's own reasoning refuses to ship — ProseMirror/TipTap ship a table plugin built for this.
  3. Checklist — reads like a list-style variant but needs custom list-item STATE (checked/unchecked) that insertUnorderedList/insertOrderedList have no concept of. A rung-4 engine's schema can define a checklist node; native execCommand cannot.
  4. Color / highlight pickers — execCommand ('foreColor'/'hiliteColor', false, color) genuinely exists (verified live — it produces a deprecated <font color> tag, one more sanitizer-allowlist variant, same class as Bold's <b> and strikethrough's <strike> above), but the PICKER UI (swatches, a custom-color input, contrast guardrails against this framework's own token set) is a component of its own the framework does not ship — the command working is not the same as the toolbar control existing.

None of the four are demonstrated by a real ERP screen in this repo either (102.1's grill finding still holds: even .bo-prose's rung has never been exercised against a pattern page) — the 99.4 front door applies if one ever is.

Markup

<!-- CHROME, not an engine: the framework styles the container,
     toolbar, and content area. What the toolbar DOES is your code —
     the light case below wires native contenteditable; a real editor
     library mounts inside __content instead.
     This is the RECOMMENDED ERP set: bold, italic, both lists, link.
     Everything past that is markup your sanitizer, export and print
     form all have to keep honouring. -->
<div class="bo-richtext">
  <div class="bo-richtext__toolbar" role="group" aria-label="Formatting">
    <div class="bo-richtext__group" role="group" aria-label="Text style">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="bold" aria-pressed="false"
              title="Bold (Ctrl/Cmd + B)" aria-keyshortcuts="Control+B Meta+B"><strong>B</strong></button>
    </div>
    <div class="bo-richtext__group" role="group" aria-label="Lists">
      <button class="bo-btn bo-btn--ghost bo-btn--icon" type="button" data-richtext-cmd="insertUnorderedList"
              aria-label="Bulleted list" title="Bulleted list"><span class="bo-icon bo-icon--list-bullet" aria-hidden="true"></span></button>
    </div>
  </div>
  <div class="bo-richtext__content bo-prose" contenteditable="true" data-empty
       role="textbox" aria-multiline="true" aria-label="Notes"
       data-placeholder="Add a note…"></div>
</div>
<script type="module">
  // The light case, in full — this IS the consumer's entire wiring.
  // The mousedown guard is load-bearing: without it the button press
  // blurs the field and collapses the selection before the command runs.
  document.addEventListener('mousedown', (e) => {
    if (e.target.closest('[data-richtext-cmd]')) e.preventDefault();
  });
  document.addEventListener('click', (e) => {
    const btn = e.target.closest('[data-richtext-cmd]');
    if (!btn) return;
    document.execCommand(btn.dataset.richtextCmd);
  });

  // Toggle state follows the SELECTION, not the click. Ctrl/Cmd+B is
  // native and fires no click, so a click-only sync leaves the button
  // reading "off" while the text is bold — wrong for exactly the users
  // who rely on it. This also covers merely moving the caret into bold
  // text, which no click covers either.
  document.addEventListener('selectionchange', () => {
    const field = document.activeElement?.closest?.('.bo-richtext');
    if (!field) return;
    for (const btn of field.querySelectorAll('[data-richtext-cmd][aria-pressed]'))
      btn.setAttribute('aria-pressed', String(document.queryCommandState(btn.dataset.richtextCmd)));
  });

  // Placeholder. CSS cannot detect an empty contenteditable — :empty stops
  // matching the moment the browser leaves a <br> behind — so emptiness is
  // reported here, in one line, and styled by [data-empty] in CSS.
  document.addEventListener('input', (e) => {
    const el = e.target.closest?.('.bo-richtext__content');
    if (el) el.toggleAttribute('data-empty', !el.textContent.trim());
  });
</script>

Quick reference generated

ClassRole
.bo-btn block (root)
.bo-richtext block (root)
.bo-richtext--disabled variant: disabled
.bo-richtext--readonly variant: readonly
.bo-richtext__content part: content
.bo-richtext__divider part: divider
.bo-richtext__group part: group
.bo-richtext__toolbar part: toolbar
.bo-richtext__toolbar-collapse part: toolbar-collapse
.bo-richtext__toolbar-toggle part: toolbar-toggle

State attributes: data-emptydata-placeholderaria-expandedaria-invalid

API generated from CSS

Blocks bo-btnbo-richtext
Parts bo-richtext__contentbo-richtext__dividerbo-richtext__groupbo-richtext__toolbarbo-richtext__toolbar-collapsebo-richtext__toolbar-toggle
Variants bo-richtext--disabledbo-richtext--readonly
data-* hooks data-emptydata-placeholder
ARIA styled aria-expandedaria-invalid
Component tokens --bo-richtext-border
JS required None shipped — deliberately. The chrome is CSS-only; the toolbar's actions are consumer code (six lines for the native light case, or a mounted editor library's commands).
  • The engine is refused, not forgotten: undo, sanitization, and selection belong to a dedicated editor library — mount one inside __content (recipe above) when notes outgrow bold/italic/lists.
  • Two-channel states: --readonly/--disabled are the visible half; pair them with contenteditable="false" and aria-readonly/aria-disabled — and DISABLED toolbars need disabled on the buttons themselves (pointer-events stops the mouse, not Tab/Enter). Read-only content needs tabindex="0" to stay reachable and scrollable by keyboard.
  • Give the content area an aria-label always. role="textbox" aria-multiline="true" fits PLAIN notes (bold/italic in a paragraph); drop the role when the content is structured prose (headings, tables) or when you mount an engine — a textbox role flattens structure for screen readers.
  • Toggle buttons carry aria-pressed, and the wiring must sync it on selectionchange, NOT on click: Ctrl/Cmd+B is native and fires no click, so a click-only sync leaves Bold reading "off" while the text is bold — wrong for exactly the users who rely on it. The same fix covers moving the caret into already-bold text. .bo-btn[aria-pressed="true"] ships a gated pressed style, so Bold-is-on is visible and announced.
  • Keyboard shortcuts are NATIVE contenteditable — bold, italic, underline, undo/redo, select-all — and cost you nothing. Do not wire your own keydown for them; you would shadow the browser and give up its IME and undo-grouping behaviour. Advertise them with title plus aria-keyshortcuts, and see the generated keyboard map above; it comes from the @keymap block in richtext.css.
  • Placeholder: set data-placeholder and toggle data-empty from your input handler (one line). CSS alone cannot do this — :empty stops matching once the browser leaves a <br> behind, so a pure-CSS placeholder works exactly once.
  • Invalid: put aria-invalid="true" on the content element and the field reddens its own border, the same as .bo-input and .bo-select. The border routes through --bo-richtext-border, so it is retintable from outside too.
  • The toolbar never prints — editor chrome is a control, not content, so it is hidden in the print layer alongside dialog and [popover].
  • Collapsible toolbar: .bo-richtext__toolbar-toggle with aria-expanded + aria-controls hides every other button and stays visible itself, so the field still advertises that it is rich.
  • Displaying stored notes is the standalone Prose component — this page is only the EDITOR chrome.
  • Sanitize stored HTML on the server no matter which editor produced it — rich text is untrusted input, and execCommand output varies by engine (see the caveats above). A tag allowlist alone is NOT enough: stripping <script> tags leaves their text behind ("alert(1)" prints in the note) — remove script/style elements with their content, then filter the rest (dogfood-verified against the reference PO app).
  • Sizing: the min-height scales with density (--bo-density-row-height × 3) and the field is vertically resizable like a textarea; text inset matches .bo-input so it aligns with the fields around it.
Contrast — 25 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
text-muted bg-surface 7.56 6.65
text-muted bg-muted 6.87 5.66
bg-surface text-primary 17.74 16.15
danger-text bg-surface 6.47 8.89
success-text bg-surface 5.02 12.02
accent bg-surface 5.47 9.07
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-primary bg-surface-raised 17.74 14.51
border-control bg-surface 4.83 6.65
--bo-state-error-color bg-surface 4.83 6.1
border-control bg-hover 4.39 5.66
border-control bg-muted 4.39 5.66
focus-ring bg-canvas 3.58 5.05
accent bg-muted 4.97 7.72
warning-strong bg-muted 4.56 6.69
warning-strong bg-surface 5.02 7.86
danger bg-muted 4.39 5.19
text-primary bg-muted 16.12 13.75
success-text bg-muted 4.56 10.23
danger-text bg-muted 5.88 7.57

Maturity generated

Introduced
v0.3.0
CSS floor
Chrome/Edge 105 · Firefox 121 · Safari 15.4
Alignment scored
2026-08-21
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.3.0. The floor is derived from this component's stylesheet alone — set by :has() — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.

Rule Score Evidence
Typography 3 / 3 no raw font-size
Colour 3 / 3 zero raw hex
Spacing 3 / 3 zero raw dimension literals
Interaction 3 / 3 CHROME, not an engine (Slice 22 ADR): style a native contenteditable for the light case or mount a real editor inside __content — the consumer keeps the engine
Content 3 / 3 opener names the wrong context and the alternative: an ordinary ERP note field is textarea.bo-input, and a value that is already rich is displayed with .bo-prose rather than a disabled editor (102.1 grill, 2026-08-21)
Fit 3 / 3 'ready, not coupled', the same stance as htmx; the page says when to mount a real editor instead
↑↓ move Enter open Esc close