code-editor
<code-editor> (module @johnhenry/domkit/code-editor, new in domkit 0.1.1) is an editable code field with syntax
highlighting for JavaScript, CSS and HTML that works like a <textarea>. It has a textarea’s value and selection API,
submits with forms, and fires input and change at the same moments. On top of that it handles the keys you expect in
a code editor: Tab to indent, Enter to keep indentation, and auto-closed brackets and quotes. Every one of those edits
goes on the browser’s own undo stack, so Ctrl/Cmd+Z undoes it like typing.
Highlighting is <code-color>’s: the same tokenizer, the same language values and the same
::highlight(domkit-*) names, so one theme colors both. Each editor listens only to its own textarea and re-tokenizes
incrementally, so a page with hundreds of small editors stays cheap.
Every attribute, property, method and event is listed in the Reference; this page explains how to use them.
<script type="module" src="https://esm.sh/@johnhenry/domkit/code-editor/global.mjs"></script><link rel="stylesheet" href="https://esm.sh/@johnhenry/domkit/code-editor/index.css" />
<form> <label for="code">Script</label> <code-editor id="code" name="code" language="js" rows="4"><pre>function greet(name) { return `Hello, ${name}!`;}</pre> </code-editor> <button>Save</button></form>The initial text is the editor’s content: a <textarea>, <pre> or <code> inside it, or plain text (with one leading
newline dropped, like a textarea). A <pre> (or the <pre><code class="language-…"> a Markdown renderer emits) stays
readable before the script loads, and a <textarea> even stays editable; in either case write HTML escapes (<) as
you normally would. A value attribute, if present, wins.
Read and write it like a textarea:
import "@johnhenry/domkit/code-editor/global.mjs";
const editor = document.querySelector("code-editor");editor.value = "console.log(1 + 1);"; // no events, like textarea.valueeditor.addEventListener("input", () => preview(editor.value));editor.setSelectionRange(0, 7);<code-editor> is form-associated, through ElementInternals. Inside a <form> it submits its value under name, a
<label for> labels it, and form.reset() restores defaultValue (the value attribute, else the initial text).
required, disabled (also inherited from a disabled <fieldset>) and readonly behave as on a textarea, and so do
validity, checkValidity(), reportValidity() and setCustomValidity().
input fires when the user changes the value (typing, pasting, undo, or an editing key) and change when the field
loses focus with a different value than it had when it got focus. Both are fired from the element, so event.target is
the <code-editor>; they carry the textarea’s inputType and data. Setting value or calling setRangeText() from
script fires nothing, as for a textarea.
Defaults follow <textarea>
Section titled “Defaults follow <textarea>”The element follows <textarea> wherever it can:
- Height: at least
rowslines tall, default 2. - Wrapping: long lines wrap when
wrapis absent,softorhard;wrap="off"scrolls them sideways. - Length and keyboard hints:
maxlength,minlength,inputmodeandenterkeyhintare passed to the textarea and behave natively. Typing stops atmaxlength, andvalidityreportstooLong/tooShortonly for a value the user typed, as a textarea does. autofocusfocuses the editor when it is first connected, if nothing else has focus.- Properties mirror a textarea’s:
maxLength/minLengthare-1when absent,wrapreflects the attribute as written (""when absent),typeis"textarea", andtextLength,selectionStart,selectionEnd,selectionDirection,setSelectionRange(),setRangeText()andselect()all work.
These are the deliberate exceptions:
-
It grows with its content. A textarea stays at
rowslines and scrolls; the editor treatsrowsas a minimum. Cap it with CSS and it scrolls, keeping the caret in view:code-editor { max-height: 20lh; } -
Spellcheck, autocapitalize, autocorrect and autocomplete are off by default, because code isn’t prose. Set any of them on the element (
spellcheck="true",autocapitalize="sentences", …) and it is passed to the textarea. -
wrap="hard"wraps likesoft, but the submitted value is not hard-wrapped (the element submits its value itself). -
No
colsordirname. Size it with CSS. -
Tab indents. Escape, then Tab, moves focus out (see Keyboard).
Keyboard
Section titled “Keyboard”| Key | Does |
|---|---|
| Tab | Insert spaces to the next tab stop; with a selection, indent every selected line |
| Shift+Tab | Outdent the current line, or every selected line |
| Escape, then Tab (or Shift+Tab) | Move focus out of the editor, like CodeMirror. Any other key in between cancels it |
| Enter | New line with the current indentation; one level more after {, [ or (; between a pair ({|}), split it onto three lines |
( [ { ' " ` |
Insert the pair (or wrap the selection in it). Quotes don’t pair next to a word character, nor brackets in front of one. no-auto-close turns this off |
) ] } ' " ` |
Next to the same character, move over it instead. A closing bracket typed as a line’s first character outdents the line |
| Backspace | Between an empty pair, delete both; in leading spaces, delete back to the previous tab stop |
tab-size (default 2) sets the spaces per indent level. Because Tab is captured for indenting, a keyboard user leaves
the editor with Escape then Tab (WCAG 2.1.2); mention it near the editor if your users may not know the convention.
With readonly or disabled, Tab moves focus as usual.
Shortcuts are yours
Section titled “Shortcuts are yours”The editor never claims a key with Ctrl or Cmd held, so those keyboard events reach the element and its ancestors uncanceled. Listen on the element for your own shortcuts:
editor.addEventListener("keydown", (event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); run(editor.value); }});To take over a key the editor does handle (say, Enter), listen in the capture phase and call preventDefault(): the
editor skips any keydown that’s already canceled.
editor.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); // the editor won't auto-indent submit(); }}, { capture: true });Styling
Section titled “Styling”index.css (optional) gives the editor a monospace font, a border, padding, scrolling and a focus ring on the whole
element, from domkit’s shared tokens (--domkit-focus-ring, --domkit-border). It imports code-color’s token colors;
restyle them with ::highlight(domkit-keyword) and friends, as for <code-color>. rows sets
--domkit-code-editor-rows.
Style the element itself (font, padding, border, background, height). The textarea and the mirror inside inherit its font and line height and must keep identical box styles, so don’t give either its own padding, border or font.
Why a textarea
Section titled “Why a textarea”The editing surface is a real <textarea>: yours, if you put one inside, or one the element makes (editor.textarea).
Its own text is transparent; an aria-hidden <pre> laid exactly over it paints the same text with the highlight
ranges. A textarea is what gets caret, selection, IME composition, mobile keyboards, native undo, plain-offset
selection and screen-reader semantics right in all three engines; an editable <pre> differs by engine in what Enter,
paste and deleting a line insert. The cost is keeping the mirror’s text, font and wrapping identical to the textarea’s,
which the element does with inline layout styles, so it works with no stylesheet.
- Browser support: Chromium, Firefox and Safari. Highlighting needs the CSS Custom Highlight API (Chromium, Safari 17.2+, Firefox 140+); without it code is shown uncolored and editing is unaffected.
- Undo grouping follows the engine. In Chromium each of the editor’s edits is its own undo step; WebKit merges consecutive keyboard edits into one, as it does for its own typing.
- Setting
valuefrom script clears the browser’s undo history, as it does for a textarea. - Some virtual keyboards and IME composition get plain textarea behavior. The editing keys work on keyboards that
report keys in
keydown; IMEs that report"Unidentified"(some Android keyboards) get no auto-closing or auto-indent, and everything else works. - Only
inputandchangecome from the element. Other events (keydown,select,focusin,focusout) come from the inner textarea and bubble through the element. - Find-in-page may count a match twice: once in the textarea, once in the mirror that paints it.
- The tokenizer is code-color’s: small, never throws, not a parser. TypeScript and JSON are colored as JavaScript.
The element’s own guide, with the generated API tables, is
src/code-editor/readme.md in the repo.