GrCodeEditor

  • code
  • code
  • API · 16 props
  • ⌨ keys
import { GrCodeEditor } from '@feugene/granularity-code/components/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

TaskComponent
Show code without editingGrCodeBlock
Compare two versionsGrDiff
Ordinary multiline textGrTextarea (the core)
A full IDE with an LSP and a file treenothing: 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

    validate returns 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-indents makes 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. validate returns a warning on the unknown {{paylink}}, with its line and the list of known fields in the message, and placeholder explains 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

    readonly keeps the text selectable and copyable but forbids the edit — a published query is copied more often than changed. A string in language is only a name: without a grammar package «sql» stays one colour, and nothing heavy loads for a view.

    Overdue by customerPublished by Grace Wu
    select 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

PropTypedefaultDescription
languageGrCodeEditorLanguage | 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.
maxHeightstring | number | undefinedundefinedThe maximum height of the field: a CSS string or a number in pixels.
wrapboolean | undefinedundefinedWrap long lines instead of scrolling horizontally. Not set — from `GrConfigProvider`, otherwise `false`.
lineNumbersboolean | undefinedundefinedLine numbers in the gutter. Not set — from `GrConfigProvider`, otherwise `true`.
ariaLabelstring | undefinedundefinedThe accessible name of the editor; overrides the label of `GrFormField`. Without either — from the locale ("Code editor").
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size step: the type size of the code. `md` by default; taken from `GrConfigProvider`.
requiredboolean | undefinedfalseA required field (`aria-required`). Inherited from `GrFormField`.
modelValuev-modelstring | undefined""The text in the editor (`v-model`). An empty string by default.
placeholderstring | undefinedundefinedA hint in an empty editor.
tabIndentsboolean | undefinedundefined`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.
idstring | undefinedundefinedThe editor's own `id`. Not set — taken from `GrFormField`, otherwise generated.
disabledboolean | undefinedfalseThe editor is unavailable: the text cannot be edited, the field is dimmed. Inherited from `GrFormField`.
readonlyboolean | undefinedfalseThe text is visible and can be selected but not edited. Inherited from `GrFormField`.
invalidboolean | undefinedfalseThe visual and ARIA state of an error (`aria-invalid`). Inherited from `GrFormField`.

Events

EventSignatureDescription
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

MethodsSignatureDescription
focus() => Promise<void>Put the cursor into the editor. Does nothing until CodeMirror is mounted.
blur() => Promise<void>Remove focus from the editor.
getView() => unknownThe 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
import type { GrCodeEditorLanguage, GrCodeEditorProps, GrCodeEditorEmits } from '@feugene/granularity-code/components/GrCodeEditor'

Filedist/types/components/GrCodeEditor/config.d.ts

grCodeEditorConfigconst
const grCodeEditorConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeEditor">;

Filedist/types/components/GrCodeEditor/GrCodeEditor.vue.d.ts

GrCodeEditorLanguagetype
type GrCodeEditorLanguage = string | object | readonly unknown[] | (() => Promise<unknown>);
export interface GrCodeEditorProps {
GrCodeEditorPropsinterface
interface GrCodeEditorProps {
    modelValue?: string;
    language?: GrCodeEditorLanguage;
    extensions?: readonly unknown[];
    placeholder?: string;
    validate?: (value: string) => GrCodeIssue[] | Promise<GrCodeIssue[]>;
GrCodeEditorEmitsinterface
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

MinimalChangeinterface
interface MinimalChange {
    from: number;
    to: number;
    insert: string;
}
minimalChangefunction
function minimalChange(current: string, next: string): MinimalChange | null;
GrCodeIssueinterface
interface GrCodeIssue {
    from: number;
    to: number;
    severity: 'error' | 'warning' | 'info';
    message: string;
}
clampIssuesfunction
function clampIssues(issues: readonly GrCodeIssue[], length: number): GrCodeIssue[];

Filedist/types/components/GrCodeEditor/defaults.d.ts

GrCodeEditorConfigurablePropsinterface
interface GrCodeEditorConfigurableProps {
    size: GrComponentSize;
    wrap: boolean;
    lineNumbers: boolean;
    tabIndents: boolean;
}

Filedist/types/components/GrCodeEditor/safelist.d.ts

grCodeEditorSafelistconst
const grCodeEditorSafelist: string[];

Accessibility

APG pattern

редактируемая область

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.

vue
<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.

vue
<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:

vue
<GrCodeEditor v-model="config" :validate="checkJson" />
ts
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 while validate is 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:

bash
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.