GrCodeEditor
Overview
When to take it
- A config in an admin area that lies in the database as text and is edited by hand in a
<textarea>with no line numbers and no chance of noticing a missing comma. - A letter template: the same case, but the text is longer and is edited more often.
- Any form field whose value is code rather than prose.
When to take something else
| Task | Component |
|---|---|
| Show code without editing | GrCodeBlock |
| Compare two versions | GrDiff |
| Ordinary multiline text | GrTextarea (the core) |
| A full IDE with an LSP and a file tree | nothing: the package does not do that and will not |
Examples 3 each with a live demo and its code
A JSON config checked in place
validatereturns issues with positions, and the editor lists them under the text — each message names its line, because «invalid JSON» alone leaves the searching to the reader. The function may be async: syntax is checked locally, rules «on the server», and both come back through one contract.tab-indentsmakes Tab indent, and the editor itself says how to leave.Feature flags · productionStored as JSON, applied on save{ "new-invoice-editor": { "enabled": true, "rollout": 25 }, "sepa-instant": { "enabled": true, "rollout": 140 }, "dark-pdf-export": { "enabled": false, "rolout": 0 }}Press Escape, then Tab, to leave the editor
An email template with placeholders
Prose with placeholders reads best wrapped.
validatereturns awarningon the unknown{{paylink}}, with its line and the list of known fields in the message, andplaceholderexplains an empty template.Payment reminderSent 3 days after the due date Hi {{customer.name}},invoice {{invoice.number}} for {{invoice.amount}} was due on {{invoice.due}}. You can pay it in a minute here: {{paylink}}Thanks,The Acme Billing team- Line 3, column 112 — Warning: Line 3: «paylink» is not a known field. Known: customer.name, invoice.number, invoice.amount, invoice.due, pay_link.
A published query and its draft
readonlykeeps the text selectable and copyable but forbids the edit — a published query is copied more often than changed. A string inlanguageis only a name: without a grammar package «sql» stays one colour, and nothing heavy loads for a view.Overdue by customerPublished by Grace Wuselect c.name, count(*) as invoices, sum(i.amount) as overduefrom invoices ijoin customers c on c.id = i.customer_idwhere i.status = 'sent' and i.due < current_dategroup by c.nameorder by overdue desc
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
language | GrCodeEditorLanguage | undefined | "text" | The language: a string is a name for the built-in parser (`json`, `text`), an object or an array is a CodeMirror grammar, a function is its lazy loading. `text` by default. |
maxHeight | string | number | undefined | undefined | The maximum height of the field: a CSS string or a number in pixels. |
wrap | boolean | undefined | undefined | Wrap long lines instead of scrolling horizontally. Not set — from `GrConfigProvider`, otherwise `false`. |
lineNumbers | boolean | undefined | undefined | Line numbers in the gutter. Not set — from `GrConfigProvider`, otherwise `true`. |
ariaLabel | string | undefined | undefined | The accessible name of the editor; overrides the label of `GrFormField`. Without either — from the locale ("Code editor"). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size step: the type size of the code. `md` by default; taken from `GrConfigProvider`. |
required | boolean | undefined | false | A required field (`aria-required`). Inherited from `GrFormField`. |
modelValuev-model | string | undefined | "" | The text in the editor (`v-model`). An empty string by default. |
placeholder | string | undefined | undefined | A hint in an empty editor. |
tabIndents | boolean | undefined | undefined | `Tab` inserts an indent instead of moving focus. **Off** by default: an editor in a form that cannot be left by keyboard is a trap, and the user will never reach the "Save" button. When on, focus is released with `Esc`, and a note under the field says so. |
id | string | undefined | undefined | The editor's own `id`. Not set — taken from `GrFormField`, otherwise generated. |
disabled | boolean | undefined | false | The editor is unavailable: the text cannot be edited, the field is dimmed. Inherited from `GrFormField`. |
readonly | boolean | undefined | false | The text is visible and can be selected but not edited. Inherited from `GrFormField`. |
invalid | boolean | undefined | false | The visual and ARIA state of an error (`aria-invalid`). Inherited from `GrFormField`. |
extensions | readonly unknown[] | undefined | — | Raw CodeMirror extensions — an escape hatch for everything the props do not cover. |
validate | ((value: string) => GrCodeIssue[] | Promise<GrCodeIssue[]>) | undefined | — | Notes about the code: an underline at the spot and a mark in the gutter. A contract, not a linter: the same prop takes the result of `JSON.parse`, a YAML schema or a server validation response — a scenario no ready-made linter has at all. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: string): void | — |
change | (event: "change", value: string): void | — |
focus | (event: "focus", event: FocusEvent): void | — |
blur | (event: "blur", event: FocusEvent): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
focus | () => Promise<void> | Put the cursor into the editor. Does nothing until CodeMirror is mounted. |
blur | () => Promise<void> | Remove focus from the editor. |
getView | () => unknown | The live `EditorView` — **an escape hatch with no contract**. Everything the package has not covered with props is done through it; for that case we offer no guarantees. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrCodeEditorLanguage, GrCodeEditorProps, GrCodeEditorEmits } from '@feugene/granularity-code/components/GrCodeEditor'Filedist/types/components/GrCodeEditor/config.d.ts
const grCodeEditorConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeEditor">;Filedist/types/components/GrCodeEditor/GrCodeEditor.vue.d.ts
type GrCodeEditorLanguage = string | object | readonly unknown[] | (() => Promise<unknown>);
export interface GrCodeEditorProps {interface GrCodeEditorProps {
modelValue?: string;
language?: GrCodeEditorLanguage;
extensions?: readonly unknown[];
placeholder?: string;
validate?: (value: string) => GrCodeIssue[] | Promise<GrCodeIssue[]>;interface GrCodeEditorEmits {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
}Filedist/types/components/GrCodeEditor/editorState.d.ts
interface MinimalChange {
from: number;
to: number;
insert: string;
}function minimalChange(current: string, next: string): MinimalChange | null;interface GrCodeIssue {
from: number;
to: number;
severity: 'error' | 'warning' | 'info';
message: string;
}function clampIssues(issues: readonly GrCodeIssue[], length: number): GrCodeIssue[];Filedist/types/components/GrCodeEditor/defaults.d.ts
interface GrCodeEditorConfigurableProps {
size: GrComponentSize;
wrap: boolean;
lineNumbers: boolean;
tabIndents: boolean;
}Filedist/types/components/GrCodeEditor/safelist.d.ts
const grCodeEditorSafelist: string[];Accessibility
APG pattern
редактируемая область
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
Tab moves the focus away — and that is a decision, not an omission
A code editor in a form that cannot be left from the keyboard is a trap: the user will not reach the “Save” button, and not everyone will manage to get around it with a mouse.
Indentation with Tab is switched on with the tabIndents prop. The standard CodeMirror device then
works — Esc returns to Tab the role of moving on — and a hint about that stands under the field:
changing the behaviour of a key silently is not allowed.
The language comes from the consumer
In CodeMirror every language is a separate npm package. A built-in set would either drag into the bundle languages you do not need, or turn into an optional peer with a dynamic import that breaks the build for anyone who has not installed the package.
<script setup>
import { json } from '@codemirror/lang-json'
</script>
<GrCodeEditor v-model="config" :language="json()" />The tick is a working mode rather than an ornament: the editor is drawn as text at once and gets highlighted when the language has arrived.
<GrCodeEditor :language="() => import('@codemirror/lang-yaml').then(m => m.yaml())" />If there is no grammar, the built-in parsing works: language="json" is coloured by the same
tokeniser as GrCodeBlock, through a bridge into CodeMirror decorations (docs/highlight.md). A
config in a form therefore looks the same before mounting and after, and a block standing next to it
does not turn out to be the only coloured thing on the page.
For any other language the string in language is only a name: there is no built-in parsing behind
it, and the text stays one colour until a grammar arrives.
The colour in both cases comes from the same --gr-code-block-* tokens: the themes of CodeMirror are
not taken in any form, otherwise the editor would become the only element of the page that does not
obey the theme of the application. There are ten roles, and the correspondence with the Lezer tags is
in docs/highlight.md.
Validation is a contract rather than a linter
@codemirror/lint is not connected: it brings a tooltip and a panel of its own that will not match
the design system. Instead of a dependency there is a prop:
<GrCodeEditor v-model="config" :validate="checkJson" />function checkJson(value: string) {
try {
JSON.parse(value)
return []
}
catch (error) {
return [{ from: 0, to: value.length, severity: 'error' as const, message: String(error) }]
}
}The prop works more broadly than a linter: the same contract gives away a YAML schema or the answer
of server-side validation — a scenario a ready linter does not have at all. The remarks are linked
to the field through aria-describedby, so an error stays available without sight rather than only
as a coloured wave under the text.
Each remark is visible in three places, and all three agree:
- a wavy underline over the range
[from, to)in the tone of its severity (error,warning,info); a point remark (from === to) is widened to one character — otherwise there is nothing to underline; - a mark in the gutter on the line of the remark, with the text of the line’s remarks in
title. The column of marks stands whilevalidateis set and keeps its width even without remarks: otherwise the text would jump sideways at every change of verdict; - a list item under the field starts with the line and the column — “Line 3, column 11 — Error: …” — so that the list and the underline can be matched up.
The answer of an asynchronous validate reaches both the underline and the gutter: the remarks go
into the live editor as an effect, without re-creating it — the cursor and the history stay intact.
v-model does not reset the cursor
A naive wrapper over CodeMirror replaces the document as a whole and thereby resets the cursor, the
selection and the undo history — on every round trip of v-model, that is, on every letter if
the parent puts the value into a ref and returns it back.
Here an incoming change is applied as a transaction with a minimal replacement: the common prefix and suffix are computed, and only the middle is changed. A change born in the editor itself is not applied back — the transaction is marked, and no echo loop arises.
The name of the field lives inside the editor
CodeMirror hangs the textbox role and contenteditable not on the node the component mounts but on
the .cm-content inside it. A name left on the wrapper does not reach the accessibility tree — the
widget reads as nameless, and axe reports aria-input-field-name.
The component therefore puts aria-label, aria-labelledby, aria-describedby, aria-invalid and
aria-required onto the editable node through EditorView.contentAttributes and updates them by
reconfiguration, without recreating the state. From the outside that is invisible: the label is still
taken from GrFormField, and ariaLabel overrides it outside a field.
The practical conclusion for the consumer: hanging ARIA attributes of your own on the root of the component is pointless — they will not reach the widget. Everything that has to be heard is passed with props.
CodeMirror is an optional peer
A package taken for the sake of GrCodeBlock or GrDiff is not obliged to install CodeMirror. The
editor without it will say so honestly in dev mode and will show the code as text rather than bring
the application down.
If the editor is needed, four packages are installed:
yarn add @codemirror/state @codemirror/view @codemirror/language @codemirror/commands@codemirror/commands is not optional: it holds history, and an editor without undo is broken.
@codemirror/search is not in the set — a search over a config of forty lines is not needed, and the
search panel brings markup of its own; whoever needs it adds it through extensions.
Limits
Not an IDE: no LSP, no multiple cursors, no file tree, no tabs. Everything the props do not cover is
available through getView() — a live EditorView, declared an escape hatch with no contract.
Props in this example