Markdown editor
A comment box: a formatting bar above a <textarea>, and a panel that completes @names
and :emoji: under the caret. Two elementals and no editor framework, no contenteditable,
no markdown parser.
The glue is about a hundred lines, and two thirds of it is the completion panel β the token
regex, the caret measuring, the ranking β which is
covered on the suggest page and repeated
here so the sample runs on its own. The toolbar's share is the twenty-odd lines under
wrap, prefix and insert.
<div class="editor">
<toolbar-elemental aria-label="Formatting" aria-controls="body">
<div role="group" aria-label="Text">
<tooltip-elemental>
<button type="button" title="Bold" data-wrap="**">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 2h4.5a3.501 3.501 0 0 1 2.852 5.53A3.499 3.499 0 0 1 9.5 14H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm1 7v3h4.5a1.5 1.5 0 0 0 0-3Zm3.5-2a1.5 1.5 0 0 0 0-3H5v3Z"/></svg>
</button>
<span>Bold</span>
</tooltip-elemental>
<tooltip-elemental>
<button type="button" title="Italic" data-wrap="_">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 2.75A.75.75 0 0 1 6.75 2h6.5a.75.75 0 0 1 0 1.5h-2.505l-3.858 9H9.25a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.505l3.858-9H6.75A.75.75 0 0 1 6 2.75Z"/></svg>
</button>
<span>Italic</span>
</tooltip-elemental>
<tooltip-elemental>
<button type="button" title="Code" data-wrap="`">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/></svg>
</button>
<span>Code</span>
</tooltip-elemental>
</div>
<div role="group" aria-label="Blocks">
<tooltip-elemental>
<button type="button" title="Heading" data-prefix="## ">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.75 2a.75.75 0 0 1 .75.75V7h7V2.75a.75.75 0 0 1 1.5 0v10.5a.75.75 0 0 1-1.5 0V8.5h-7v4.75a.75.75 0 0 1-1.5 0V2.75A.75.75 0 0 1 3.75 2Z"/></svg>
</button>
<span>Heading</span>
</tooltip-elemental>
<tooltip-elemental>
<button type="button" title="Quote" data-prefix="> ">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z"/></svg>
</button>
<span>Quote</span>
</tooltip-elemental>
<tooltip-elemental>
<button type="button" title="Bulleted list" data-prefix="- ">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/></svg>
</button>
<span>Bulleted list</span>
</tooltip-elemental>
</div>
<div role="group" aria-label="Insert">
<tooltip-elemental>
<button type="button" title="Mention" data-insert="@">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 .5a7.499 7.499 0 0 1 7.499 7.462l.002.038v1.164a2.612 2.612 0 0 1-4.783 1.454A3.763 3.763 0 0 1 8 11.776 3.776 3.776 0 1 1 11.776 8v1.164a1.112 1.112 0 0 0 2.225 0L14 8a6 6 0 1 0-3.311 5.365.75.75 0 0 1 .673 1.341A7.5 7.5 0 1 1 8 .5Zm0 5.225a2.275 2.275 0 1 0 0 4.552 2.275 2.275 0 0 0 0-4.552Z"/></svg>
</button>
<span>Mention</span>
</tooltip-elemental>
<tooltip-elemental>
<button type="button" title="Emoji" data-insert=":">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm3.82 1.636a.75.75 0 0 1 1.038.175l.007.009c.103.118.22.222.35.31.264.178.683.37 1.285.37.602 0 1.02-.192 1.285-.371.13-.088.247-.192.35-.31l.007-.008a.75.75 0 0 1 1.222.87l-.022-.015c.02.013.021.015.021.015v.001l-.001.002-.002.003-.005.007-.014.019a2.066 2.066 0 0 1-.184.213c-.16.166-.338.316-.53.445-.63.418-1.37.638-2.127.629-.946 0-1.652-.308-2.126-.63a3.331 3.331 0 0 1-.715-.657l-.014-.02-.005-.006-.002-.003v-.002h-.001l.613-.432-.614.43a.75.75 0 0 1 .183-1.044ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM5 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5.25 2.25.592.416a97.71 97.71 0 0 0-.592-.416Z"/></svg>
</button>
<span>Emoji</span>
</tooltip-elemental>
</div>
</toolbar-elemental>
<div class="composer">
<label class="visually-hidden" for="body">Comment</label>
<textarea id="body" rows="6" autocomplete="off">Ship it @</textarea>
<div class="at-caret">
<suggest-elemental for="body" tab-completes><ul></ul></suggest-elemental>
</div>
</div>
</div>
.editor { display: grid; gap: 0.5rem; max-width: 34rem; }
/* an icon button is square, and the icon is drawn in the text colour it inherits */
.editor toolbar-elemental button { padding: 0.4rem; }
.editor toolbar-elemental svg { display: block; fill: currentcolor; }
.composer { position: relative; padding-block-end: 9rem; }
/* the same rim and corner the toolbar theme draws above it, so the bar and the field read
as one composer rather than two boxes that happen to be stacked */
.composer textarea {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
padding: 0.5rem; font: inherit; color: inherit;
background: none;
border: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
border-radius: 0.375rem;
}
/* the panel hangs off a box of no size that the page moves to the caret */
.at-caret { position: absolute; inset-block-start: 0; inset-inline-start: 0; width: 0; height: 0; }
/* `width: max-content` because the anchor above is a box of no size: an absolutely
positioned panel shrinks to fit its containing block, and a containing block of zero
width wraps every row. The max-height is capped under the element's own 20rem so the
emoji list scrolls inside the space this sample reserves below the field */
.at-caret suggest-elemental {
min-width: 12rem; width: max-content; max-width: 20rem;
--suggest-elemental-max-height: 10rem;
}
.at-caret small { opacity: 0.65; margin-inline-start: 0.4rem; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
const field = document.getElementById('body');
const bar = document.querySelector('toolbar-elemental');
const panel = document.querySelector('suggest-elemental');
const anchor = document.querySelector('.at-caret');
// `insertText` rather than assigning to `value`, and this is the whole reason the buttons
// are worth having: setting `value` wipes the field's undo stack, so the first Ctrl+Z after
// a click undoes everything the reader typed. execCommand is deprecated and is still the
// only way to write into a textarea as if it had been typed.
function type(text) {
field.focus();
document.execCommand('insertText', false, text);
}
// Bold, Italic, Code: wrap what is selected, or open a pair and sit between the marks.
function wrap(mark) {
const { selectionStart: start, selectionEnd: end, value } = field;
const selected = value.slice(start, end);
field.setSelectionRange(start, end);
type(mark + selected + mark);
const caret = start + mark.length + selected.length;
field.setSelectionRange(caret, caret);
}
// Quote, List: mark every line the selection touches, whole lines, whether or not the
// selection reached their ends.
function prefix(mark) {
const { selectionStart: start, selectionEnd: end, value } = field;
const from = value.lastIndexOf('\n', start - 1) + 1;
const breakAfter = value.indexOf('\n', end);
const to = breakAfter === -1 ? value.length : breakAfter;
field.setSelectionRange(from, to);
type(value.slice(from, to).split('\n').map((line) => mark + line).join('\n'));
}
// Mention, Emoji: plant the trigger the panel watches for and let the `input` event that
// `insertText` fires open it, same as typing the character would. The space is the part a
// button has to add and a reader never does: the token only counts at a word start, so a
// click with the caret against a word would otherwise write an `@` that nothing completes.
function insert(trigger) {
const before = field.value.slice(0, field.selectionStart);
type(/\S$/.test(before) ? ' ' + trigger : trigger);
}
bar.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
if (button.dataset.wrap) wrap(button.dataset.wrap);
else if (button.dataset.prefix) prefix(button.dataset.prefix);
else if (button.dataset.insert) insert(button.dataset.insert);
});
// Shortcode to glyph, because that is the shape this data has everywhere else β the rows the
// panel wants are built off it below.
const EMOJI = {
tada: 'π', fire: 'π₯', rocket: 'π', sparkles: 'β¨', bug: 'π', eyes: 'π',
joy: 'π', smile: 'π', wink: 'π', thinking: 'π€', sob: 'π', rage: 'π‘',
sunglasses: 'π', heart: 'β€οΈ', thumbsup: 'π', thumbsdown: 'π', clap: 'π',
pray: 'π', muscle: 'πͺ', wave: 'π', ok_hand: 'π', brain: 'π§ ', skull: 'π',
ghost: 'π»', robot: 'π€', poop: 'π©', star: 'β', zap: 'β‘', boom: 'π₯',
rainbow: 'π', coffee: 'β', beer: 'πΊ', pizza: 'π', cake: 'π', gift: 'π',
bell: 'π', lock: 'π', key: 'π', hammer: 'π¨', wrench: 'π§', gear: 'βοΈ',
package: 'π¦', books: 'π', memo: 'π', chart: 'π', calendar: 'π
', mag: 'π',
warning: 'β οΈ', construction: 'π§', white_check_mark: 'β
', x: 'β',
question: 'β', snake: 'π', whale: 'π³', cat: 'π±', dog: 'πΆ', unicorn: 'π¦',
penguin: 'π§'
};
const ROWS = {
'@': [
{ value: '@stamat', hint: 'Nikola StamatoviΔ' },
{ value: '@koyev', hint: 'Marko JeviΔ' },
{ value: '@msavin', hint: 'Max Savin' },
{ value: '@jesussandreas', hint: 'Jesus Sandrea' }
],
':': Object.entries(EMOJI).map(([name, glyph]) => ({ value: glyph, hint: `:${name}:` }))
};
const escapeText = (value) => value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
const TOKEN = /(?:^|\s)([@:])([\p{L}\p{N}_+-]*)$/u;
function token() {
const hit = TOKEN.exec(field.value.slice(0, field.selectionStart));
if (!hit) return null;
return { trigger: hit[1], query: hit[2].toLowerCase(), at: field.selectionStart - hit[1].length - hit[2].length };
}
function caretPoint() {
const style = getComputedStyle(field);
const mirror = document.createElement('div');
for (const prop of ['font', 'padding', 'border', 'width', 'boxSizing', 'letterSpacing']) mirror.style[prop] = style[prop];
mirror.style.cssText += ';position:absolute;visibility:hidden;white-space:pre-wrap;overflow-wrap:break-word';
mirror.textContent = field.value.slice(0, field.selectionStart);
const caret = mirror.appendChild(document.createElement('span'));
caret.textContent = '.';
document.body.append(mirror);
const point = {
x: field.offsetLeft + caret.offsetLeft,
y: field.offsetTop + caret.offsetTop + parseFloat(style.lineHeight) - field.scrollTop
};
mirror.remove();
return point;
}
function refresh() {
const now = token();
if (!now) {
panel.open = false;
return;
}
const hits = ROWS[now.trigger].filter((row) => (row.value + ' ' + row.hint).toLowerCase().includes(now.query));
panel.innerHTML = '<ul>' + hits.map((row) =>
`<li><a href="#" data-value="${escapeText(row.value)}">${escapeText(row.value)}<small>${escapeText(row.hint)}</small></a></li>`
).join('') + '</ul>';
const point = caretPoint();
anchor.style.transform = `translate(${point.x}px, ${point.y}px)`;
panel.open = hits.length > 0;
if (panel.open) queueMicrotask(() => panel.moveTo(0));
}
field.addEventListener('input', refresh);
field.addEventListener('click', refresh);
panel.addEventListener('click', (event) => {
const row = event.target.closest('a[data-value]');
if (!row) return;
event.preventDefault();
const now = token();
if (!now) return;
field.setSelectionRange(now.at, field.selectionStart);
type(row.dataset.value + ' ');
});Select a word and press Bold. Type @sta or :fi in the field, or press Mention or
Emoji and let the panel open on an empty query. Then press
Ctrl/Cmd + Z β every one of those undoes, in order, because
none of this ever assigned to value.
The bar is one tab stop
Eight buttons above a field is eight presses of Tab between the reader and the field
they came to write in. <toolbar-elemental> makes it one:
Tab enters the bar, β and β walk it, Tab again
lands in the <textarea>.
The bar is named, and it says what it acts on:
<toolbar-elemental aria-label="Formatting" aria-controls="body">
aria-controls is the page's, not the element's β a toolbar sitting above a field it does
not name is a group of buttons whose target a screen reader user has to infer from where it
happens to be on screen. It is
valid on every role,
toolbar among them.
The buttons are not toggles. No aria-pressed anywhere, because knowing whether the
selection is already bold means parsing markdown, and an aria-pressed="false" that is
sometimes a lie is worse than no state at all. These are actions: they insert marks.
Groups divide, they do not stop
The three role="group"s split text marks from block marks from the two buttons that open
the completion panel. That is a real division β a screen reader announces the group and its
label on entering it β and it is not a stop the keyboard has to get into and out of. The
arrows run straight through: eight controls, one sequence, β from the first
heading button lands on the code button in the group before it.
The theme draws the rule between them off [role="group"] + [role="group"], so the line
appears between groups and never hangs off either end of the bar.
The tooltip is the name, said once
Every button here is icon-only, and its tooltip carries the name and nothing else β "Bold",
not a sentence about what bold does. That is the one case where a tooltip may be a name,
and the markup that gets it right is the title shape:
<tooltip-elemental>
<button type="button" title="Bold" data-wrap="**">
<svg aria-hidden="true">β¦</svg>
</button>
<span>Bold</span>
</tooltip-elemental>
No aria-label anywhere. <tooltip-elemental> finds a trigger
whose only name was its title β the Octicon inside is
aria-hidden="true", so there is no text to find β takes the title off, and sets
aria-label from the bubble instead. It skips aria-describedby in that case deliberately:
described and named by the same string is a screen reader saying "Bold, button, Bold".
Write aria-label="Bold" on those buttons yourself and you get exactly that double
announcement, because the trigger then has a name already and the bubble becomes a
description of it. Which one the words end up as is decided by the attribute you wrote, and
for an icon-only button title is the one.
The caveat stands either way: there is no hover on a touch screen, so the bubble is words a phone reader never sees. That is survivable here only because those same words are the accessible name, which every reader gets.
Ctrl+Z is the whole reason for insertText
The obvious way to write into a <textarea> from a button is field.value = β¦. It works,
and it silently empties the field's undo stack β so the first Ctrl+Z after a click throws
away everything the reader typed before it, and nothing warns them.
document.execCommand("insertText", β¦) writes the same text as if it had been typed:
the undo stack survives, input fires, and the caret lands where typing would leave it. It
is deprecated and
it is still the only way to do this β the EditContext API is for canvas-style editors, not
for form controls. That is the trade, taken knowingly: a deprecated call that works today
against a supported call that loses a reader's work.
The panel is the page's
<suggest-elemental> owns the popup, the arrows and
aria-activedescendant. Everything about what completes is here: which character triggers
it, what the query is, how rows are ranked, and what an accepted row does to the text. The
element never sees the @.
Mention and Emoji are that same seam from the other side. The button writes the
trigger character with insertText, the input event that follows opens the panel, and no
part of the completion knows a button was involved rather than a keystroke. The one thing the
button owes the reader is the space in front of it: the token only counts at a word start, so
a click with the caret against a word would otherwise plant an @ that nothing completes.
The mechanics of that β the caret-following panel, the automatic selection of the first row,
and why the rows are <a href="#"> β are covered on
the suggest page. This example only shows
them standing next to a toolbar.
What is not here
No preview pane, no syntax highlighting, no markdown parser β all three want a dependency,
and the point of the example is the two elements and the seam between them. No image upload
and no drag-and-drop either: they are the same click handler with a fetch in it, and
nothing about them is a keyboard or ARIA problem.