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
unknownand 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 —
maxHeightturns the block into a scroller reachable from the keyboard.
When to take something else
| Need | Take |
|---|---|
| The code is edited rather than read | GrCodeEditor |
| Compare two versions | GrDiff |
| Expand and collapse the nodes of the data | GrJsonViewer or GrTree (the core) |
| Collapse the block as a whole | GrCollapse (the core) |
| A “property → value” pair | GrDescriptionList (the core) |
| A key or a shortcut inside text | GrKbd (the core) |
| The value is ordinary text rather than code | GrTextarea (the core) |
Examples 4 each with a live demo and its code
A webhook delivery, request and response
codetakes 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 — andcopylets the page say «copied». The response is plainlanguage="text".invoice.paid200 OK312 ms · 6 Oct, 09:14Request 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}ResponseHTTP/1.1 200 OKcontent-type: application/jsonx-request-id: 7f3e1c9a{"received": true}A curl command with its own highlighter
highlightertakes 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 akeyword,$ACME_KEYavariable, so the theme colours them in both modes.Create your first invoiceReplace $ACME_KEYwith 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-heightturns 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 andaria-labelnames it.wrapbreaks 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 1Data 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.stringifythrows on both; the block prints[Circular]and9007199254740993ninstead — 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
| Prop | Type | default | Description |
|---|---|---|---|
language | GrCodeBlockLanguage | undefined | "json" | The highlighting language: `json` and `text` are parsed by the built-in parser, the rest by a connected highlighter. `json` by default. |
highlighter | GrCodeTokenizer | undefined | undefined | Highlighting for languages beyond the built-in ones. Unset — taken from the application's `provide`/`inject` (`GR_CODE_HIGHLIGHTER_KEY`). |
maxHeight | string | number | undefined | undefined | The height after which vertical scrolling kicks in. |
copyable | boolean | undefined | undefined | The copy button. Without a secure context it is not rendered at all. |
wrap | boolean | undefined | undefined | Wrap long lines instead of scrolling horizontally. |
lineNumbers | boolean | undefined | undefined | Line numbers in a column on the left; they do not get into the selection or the clipboard. Not set — from `GrConfigProvider`, otherwise `false`. |
ariaLabel | string | undefined | undefined | The name of the scroll area. An unnamed one is announced by a screen reader as just "region". |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size step: the type size of the code and the inner paddings. `md` by default; taken from `GrConfigProvider`. |
coderequired | unknown | — | A string or a value: an object is serialized with indentation. |
Events
| Event | Signature | Description |
|---|---|---|
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 type { GrCodeBlockLanguage, GrCodeBlockProps, GrCodeBlockEmits } from '@feugene/granularity-code/components/GrCodeBlock'Filedist/types/components/GrCodeBlock/config.d.ts
const grCodeBlockConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeBlock">;Filedist/types/components/GrCodeBlock/GrCodeBlock.vue.d.ts
type GrCodeBlockLanguage = 'json' | 'text' | (string & {});interface GrCodeBlockProps {
code: unknown;
language?: GrCodeBlockLanguage;
highlighter?: GrCodeTokenizer;
maxHeight?: string | number;
copyable?: boolean;
wrap?: boolean;
lineNumbers?: boolean;
ariaLabel?: string;
size?: GrComponentSize;
}interface GrCodeBlockEmits {
(e: 'copy', code: string): void;
}Filedist/types/highlight/palette.d.ts
type GrCodeRole = typeof GR_CODE_ROLES[number];interface GrCodeToken {
text: string;
role: GrCodeRole;
}Filedist/types/components/GrCodeBlock/defaults.d.ts
interface GrCodeBlockConfigurableProps {
size: GrComponentSize;
copyable: boolean;
wrap: boolean;
lineNumbers: boolean;
}Filedist/types/components/GrCodeBlock/safelist.d.ts
const grCodeBlockSafelist: string[];Accessibility
APG pattern
блок (если скроллер) + кнопка
Full contract of the package: docs/keyboard.md ↗
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; BigIntis printed with annsuffix — the standardJSON.stringifythrows 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 owntry: one broken field does not hide its neighbours, nor the cycles andBigIntnext 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.
Props in this example