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
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.
| Key | Does |
|---|---|
| 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 "- ", "1. " or "5. " 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> 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:
- 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. - 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.
- Checklist — reads like a list-style variant
but needs custom list-item STATE (checked/unchecked) that
insertUnorderedList/insertOrderedListhave no concept of. A rung-4 engine's schema can define a checklist node; nativeexecCommandcannot. - 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
| Class | Role |
|---|---|
| .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/--disabledare the visible half; pair them withcontenteditable="false"andaria-readonly/aria-disabled— and DISABLED toolbars needdisabledon the buttons themselves (pointer-eventsstops the mouse, not Tab/Enter). Read-only content needstabindex="0"to stay reachable and scrollable by keyboard. - Give the content area an
aria-labelalways.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 onselectionchange, NOT onclick: 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 ownkeydownfor them; you would shadow the browser and give up its IME and undo-grouping behaviour. Advertise them withtitleplusaria-keyshortcuts, and see the generated keyboard map above; it comes from the@keymapblock inrichtext.css. - Placeholder: set
data-placeholderand toggledata-emptyfrom yourinputhandler (one line). CSS alone cannot do this —:emptystops 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-inputand.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
dialogand[popover]. - Collapsible toolbar:
.bo-richtext__toolbar-togglewitharia-expanded+aria-controlshides 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-inputso it aligns with the fields around it.
Contrast — 25 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| 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 |