GrCodeBlock

  • code
  • code
  • API · 9 props
  • ⌨ keys
import { GrCodeBlock } from '@feugene/granularity-code/components/GrCodeBlock'

Overview

When to take it

  • show the answer of a service as it is — the body of a request, the answer of a model, the payload of an event: the value arrives as unknown and the structure is not known in advance;
  • it gets copied — into a ticket, into a support chat; the button copies the source text rather than what is visible on the screen;
  • the value is technical — identifiers, hashes, a config: a monospaced font and highlighting answer “this is data, not prose”;
  • the answer is long — maxHeight turns the block into a scroller reachable from the keyboard.

When to take something else

NeedTake
The code is edited rather than readGrCodeEditor
Compare two versionsGrDiff
Expand and collapse the nodes of the dataGrJsonViewer or GrTree (the core)
Collapse the block as a wholeGrCollapse (the core)
A “property → value” pairGrDescriptionList (the core)
A key or a shortcut inside textGrKbd (the core)
The value is ordinary text rather than codeGrTextarea (the core)

Examples 4 each with a live demo and its code

  • A webhook delivery, request and response

    code takes the payload as an object and serializes it with indentation; JSON is the one language the block highlights itself. The copy button puts the source into the clipboard — without the line numbers, which are a CSS counter and never part of the text — and copy lets the page say «copied». The response is plain language="text".

    invoice.paid200 OK312 ms · 6 Oct, 09:14
    Request body
    {  "id": "evt_1Q7b3x2LkS",  "type": "invoice.paid",  "created": "2026-10-06T09:14:22Z",  "data": {    "invoice": "INV-0418",    "customer": {      "id": "cus_8812",      "name": "Northwind Traders"    },    "amount": 1284.5,    "currency": "EUR",    "paid_with": "sepa_debit",    "metadata": {      "po_number": "PO-77120",      "cost_centre": null    }  },  "attempt": 1}
    Response
    HTTP/1.1 200 OKcontent-type: application/jsonx-request-id: 7f3e1c9a{"received": true}
  • A curl command with its own highlighter

    highlighter takes any function that cuts text into roles — here twenty lines of regex for shell, no Shiki or Prism. Roles are named by meaning: a flag is a keyword, $ACME_KEY a variable, so the theme colours them in both modes.

    Create your first invoiceReplace $ACME_KEY with a test key
    # Create a draft invoicecurl -X POST https://api.acme-billing.com/v1/invoices \  -H "Authorization: Bearer $ACME_KEY" \  -d customer=cus_8812 \  -d "items[0][price]=price_team_monthly"
  • A failed deploy's log

    max-height turns the block into a window with its own scroll, so a long log does not push the page off the screen; the window is a Tab stop and aria-label names it. wrap breaks long lines — a log is read wrapped, code is read as written.

    Deploy #812 · productionFailed
    09:41:02 Cloning acme/billing-web@4f2c1e909:41:05 Installing dependencies (yarn 4.5.0, node 22.11)09:41:48 Resolved 1,284 packages in 43s09:41:49 Running build: vite build --mode production09:42:11 ✓ 2,418 modules transformed09:42:12 rendering chunks…09:42:19 dist/assets/invoice-editor-3f9a1c.js  412.70 kB │ gzip: 128.33 kB09:42:19 (!) Some chunks are larger than 400 kB after minification. Consider dynamic import() to split the application09:42:20 Running checks: bundle budget09:42:20 ✗ invoice-editor exceeds its budget: 128.3 kB gzip > 120 kB09:42:20 Build failed with exit code 1
  • Data that would break JSON.stringify

    An error tracker captures whatever was in memory: a session that points back at its request, a 64-bit id as BigInt. JSON.stringify throws on both; the block prints [Circular] and 9007199254740993n instead — the page stays up while it shows what broke.

    TypeError in send-invoiceCaptured context · 14 events in the last hour
    {  "error": "TypeError: Cannot read properties of undefined (reading 'email')",  "release": "[email protected]",  "ledgerEntryId": "9007199254740993n",  "request": {    "method": "POST",    "path": "/v1/invoices/INV-0418/send",    "session": {      "id": "sess_51c0",      "user": "[email protected]",      "plan": "Team",      "lastRequest": "[Circular]"    }  }}

API generated from the package · cannot drift

Props

PropTypedefaultDescription
languageGrCodeBlockLanguage | undefined"json"The highlighting language: `json` and `text` are parsed by the built-in parser, the rest by a connected highlighter. `json` by default.
highlighterGrCodeTokenizer | undefinedundefinedHighlighting for languages beyond the built-in ones. Unset — taken from the application's `provide`/`inject` (`GR_CODE_HIGHLIGHTER_KEY`).
maxHeightstring | number | undefinedundefinedThe height after which vertical scrolling kicks in.
copyableboolean | undefinedundefinedThe copy button. Without a secure context it is not rendered at all.
wrapboolean | undefinedundefinedWrap long lines instead of scrolling horizontally.
lineNumbersboolean | undefinedundefinedLine numbers in a column on the left; they do not get into the selection or the clipboard. Not set — from `GrConfigProvider`, otherwise `false`.
ariaLabelstring | undefinedundefinedThe name of the scroll area. An unnamed one is announced by a screen reader as just "region".
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size step: the type size of the code and the inner paddings. `md` by default; taken from `GrConfigProvider`.
coderequiredunknown—A string or a value: an object is serialized with indentation.

Events

EventSignatureDescription
copy(event: "copy", code: string): void—

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 { GrCodeBlockLanguage, GrCodeBlockProps, GrCodeBlockEmits } from '@feugene/granularity-code/components/GrCodeBlock'

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

grCodeBlockConfigconst
const grCodeBlockConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeBlock">;

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

GrCodeBlockLanguagetype
type GrCodeBlockLanguage = 'json' | 'text' | (string & {});
GrCodeBlockPropsinterface
interface GrCodeBlockProps {
    code: unknown;
    language?: GrCodeBlockLanguage;
    highlighter?: GrCodeTokenizer;
    maxHeight?: string | number;
    copyable?: boolean;
    wrap?: boolean;
    lineNumbers?: boolean;
    ariaLabel?: string;
    size?: GrComponentSize;
}
GrCodeBlockEmitsinterface
interface GrCodeBlockEmits {
    (e: 'copy', code: string): void;
}

Filedist/types/highlight/palette.d.ts

GrCodeRoletype
type GrCodeRole = typeof GR_CODE_ROLES[number];
GrCodeTokeninterface
interface GrCodeToken {
    text: string;
    role: GrCodeRole;
}

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

GrCodeBlockConfigurablePropsinterface
interface GrCodeBlockConfigurableProps {
    size: GrComponentSize;
    copyable: boolean;
    wrap: boolean;
    lineNumbers: boolean;
}

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

grCodeBlockSafelistconst
const grCodeBlockSafelist: string[];

Accessibility

APG pattern

блок (если скроллер) + кнопка

Usage from the library prose

Serialisation has no right to bring the page down

code accepts unknown, because the data comes from a database and can be anything. Hence three decisions, each of which covers a real failure:

  • a circular reference is replaced with a [Circular] marker rather than hanging the tab. An object met a second time in different branches gets the marker as well: a walk by the stack of ancestors would unfold every shared branch anew, and on a “diamond” of shared references that is exponential. An imprecision here is cheaper than a freeze;
  • BigInt is printed with an n suffix — the standard JSON.stringify throws on it;
  • everything else that failed during serialisation (a hostile toJSON, a getter that throws) gives [Unserializable] — in place of that value, not instead of the whole block. The tree is walked value by value, each with its own try: one broken field does not hide its neighbours, nor the cycles and BigInt next to it.

A string goes through as it is, with no quotes and no reformatting: it is ready text already. undefined prints an empty block, and null the null literal; “there is no data” and “the value equals null” are different statements, and deciding between them is the task of the page rather than of the block.

What is copied is the source, not the screen

The button puts into the clipboard the same string that is rendered — but taken from the model rather than from the markup. The difference is visible with line numbers: copying together with them means getting text there is nowhere to paste.

The line numbers are made with a CSS counter for exactly that: as text they are not in the markup at all, so they enter neither the clipboard nor a selection with the mouse.

Without a secure context there is no button. navigator.clipboard is unavailable over http://, and a button that silently does nothing is worse than its absence. The presence of the clipboard is checked after mounting — in the first render there is no button either on the server or on the client, so the hydration matches.

A success goes into a live region (useAnnouncer) and into a copy event — the toast is shown by the consumer: the block has no place of its own for it.

The button stands beside the scroller rather than on top of it. The scrollbar is drawn by the browser at the right edge of the <pre>, and a button covering it takes away the very pixels by which the bar is grabbed with the mouse. The block therefore reserves a gutter on the right, and the code itself narrows by its width; padding does not solve that — it would have to be taken larger than the width of the button, and the corner would stop being a corner. There is no gutter when there is no button: with copyable: false and without a secure context no width is lost.

The scroller and the keyboard

The block enters the tab order when it is a scroller by props: maxHeight is set or wrap is switched off (then a long line gives horizontal scrolling). Measuring the overflow is deliberately not used: it would make the Tab stop flicker on every change of the data and on the loading of the font.

ariaLabel gives the area a role="region" and a name. A nameless area is announced by a screen reader simply as “region”, and on a page with four blocks they cannot be told apart.

The highlighting is its own and only for JSON

The tokenisation is a pure function over four roles: a key, a string, a number, a literal. Dragging in highlight.js for that is out of proportion, and language="text" switches the parsing off entirely.

The colours are references to the roles of the theme, so the highlighting works in the light and in the dark one without a theme layer of its own. The number is taken from azure rather than from info: info is a blue two steps from the indigo of primary, and the “key ↔ number” pair would merge in {"count": 42}. The contrast and the distinguishability of the roles are checked by a test rather than by eye.

Limits

It does not edit, does not diff and does not highlight languages other than JSON. It does not collapse nodes either — that is GrJsonViewer (the core), and the distinction between them is simple: text is read in full, or a field is looked for in it.