GrCodeBlock
Machine-translated from the Russian original, not yet reviewed. Read the original
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) |
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 repeat can be told from a real cycle only with the stack of ancestors, andreplacerdoes not give it away. 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) gives[Unserializable].
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.
Install
npm i @feugene/granularity-codeImport
import { GrCodeBlock } from '@feugene/granularity-code/components/GrCodeBlock'API
The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.
Examples 4
Basic
<script setup lang="ts">
import { ref } from 'vue'
import { GrSegmented } from '@feugene/granularity'
/** Ответ сервиса, как он приходит из БД: `unknown`, а не заранее известная форма. */
const response = {
id: 'ord_8241',
status: 'shipped',
total: 12490.5,
paid: true,
shipping: { carrier: 'СДЭК', track: '1094887312', days: 3 },
items: [
{ sku: 'KB-87', title: 'Клавиатура 87 клавиш', qty: 1 },
{ sku: 'MS-02', title: 'Мышь беспроводная', qty: 2 },
],
note: null,
}
const size = ref<'xs' | 'sm' | 'md' | 'lg'>('md')
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="size"
size="sm"
:options="[
{ value: 'xs', label: 'xs' },
{ value: 'sm', label: 'sm' },
{ value: 'md', label: 'md' },
{ value: 'lg', label: 'lg' },
]"
/>
<GrCodeBlock :code="response" :size="size" line-numbers copyable max-height="18rem" />
</div>
</template>Highlight
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSwitch } from '@feugene/granularity'
import type { GrCodeLine, GrCodeRole, GrCodeTokenizer } from '@feugene/granularity-code'
const SOURCE = `// Разбор конфигурации приложения
export interface AppConfig {
retries: number
featureFlags: string[]
}
export function loadConfig(raw: string): AppConfig {
const parsed = JSON.parse(raw)
return { retries: parsed.retries ?? 3, featureFlags: parsed.flags ?? [] }
}`
/**
* Подсветка для демонстрации: настоящий Shiki витрине сюда тащить незачем —
* важно показать, что подсветка приходит **функцией**, а какой она будет,
* решает приложение.
*/
const KEYWORDS = new Set(['export', 'interface', 'function', 'const', 'return', 'number', 'string'])
const demoTokenizer: GrCodeTokenizer = code => code.split('\n').map<GrCodeLine>((line) => {
if (line.trimStart().startsWith('//'))
return [{ text: line, role: 'comment' }]
return (line.match(/\w+|\W+/g) ?? []).map((part) => {
const role: GrCodeRole = KEYWORDS.has(part.trim())
? 'keyword'
: /^\d+$/.test(part.trim())
? 'number'
: 'plain'
return { text: part, role }
})
})
const highlighted = ref(true)
/**
* Подпись говорит **текущее** состояние, а не одно из двух: «Подсветка
* подключена» рядом с выключенным тумблером — прямая неправда на экране.
*/
const switchLabel = computed(() => highlighted.value
? 'Подсветка подключена'
: 'Подсветка выключена')
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="highlighted" size="sm">
{{ switchLabel }}
</GrSwitch>
<GrCodeBlock
:code="SOURCE"
language="ts"
:highlighter="highlighted ? demoTokenizer : undefined"
line-numbers
/>
</div>
</template>Shiki Theme
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import { GrButton, GrSegmented } from '@feugene/granularity'
import { createShikiTokenizer, GR_CODE_SHIKI_THEME } from '@feugene/granularity-code'
import type { GrCodeTokenizer, ShikiLike } from '@feugene/granularity-code'
/**
* Разбирает Shiki, красит тема приложения.
*
* Токен нашего контракта несёт **роль, а не цвет**: `createShikiTokenizer` даёт
* Shiki тему-метку и разбирает цвета обратно в одиннадцать ролей. Цвет ролей
* приходит из токенов `--gr-code-block-*` — поэтому «подключить тему» здесь это
* не поставить пакет, а переопределить одиннадцать переменных. Зато одна и та
* же тема разом ложится на блок, дифф и редактор, и слушается светлой/тёмной
* схемы страницы.
*/
const SOURCE = `// Пересчёт корзины после смены купона
export async function recalc(cart: Cart, coupon?: string) {
const discount = coupon ? await fetchDiscount(coupon) : 0
const total = cart.items.reduce((sum, item) => sum + item.price, 0)
return { total: total - discount, applied: discount > 0 }
}`
/**
* Палитры настоящих тем, записанные нашими токенами.
*
* Ровно то, что делает потребитель: берёт цвета любимой темы и раскладывает их
* по ролям. Ничего, кроме CSS-переменных, для этого не нужно.
*/
const PALETTES = {
'app': null,
'one-dark': {
'--gr-code-block-bg': '#282c34',
'--gr-code-block-fg': '#abb2bf',
'--gr-code-block-key': '#e06c75',
'--gr-code-block-string': '#98c379',
'--gr-code-block-number': '#d19a66',
'--gr-code-block-literal': '#d19a66',
'--gr-code-block-punctuation': '#abb2bf',
'--gr-code-block-keyword': '#c678dd',
'--gr-code-block-comment': '#5c6370',
'--gr-code-block-type': '#e5c07b',
'--gr-code-block-function': '#61afef',
'--gr-code-block-variable': '#abb2bf',
'--gr-code-block-line-number': '#4b5263',
// Дифф стоит рядом и красится теми же переменными: перекрась только код —
// подложки правок останутся светлыми и станут нечитаемыми.
'--gr-diff-added': '#2b3a2e',
'--gr-diff-removed': '#3f2b2b',
'--gr-diff-word-added': '#4b7f56',
'--gr-diff-word-removed': '#a04c4c',
'--gr-diff-word-added-fg': '#e6f4ea',
'--gr-diff-word-removed-fg': '#fbeaea',
'--gr-diff-gutter': '#5c6370',
'--gr-diff-gap-bg': '#21252b',
},
'nord': {
'--gr-code-block-bg': '#2e3440',
'--gr-code-block-fg': '#d8dee9',
'--gr-code-block-key': '#88c0d0',
'--gr-code-block-string': '#a3be8c',
'--gr-code-block-number': '#b48ead',
'--gr-code-block-literal': '#81a1c1',
'--gr-code-block-punctuation': '#eceff4',
'--gr-code-block-keyword': '#81a1c1',
'--gr-code-block-comment': '#616e88',
'--gr-code-block-type': '#8fbcbb',
'--gr-code-block-function': '#88c0d0',
'--gr-code-block-variable': '#d8dee9',
'--gr-code-block-line-number': '#4c566a',
'--gr-diff-added': '#3b4a3f',
'--gr-diff-removed': '#4a3b3f',
'--gr-diff-word-added': '#5b8a63',
'--gr-diff-word-removed': '#a3616f',
'--gr-diff-word-added-fg': '#eceff4',
'--gr-diff-word-removed-fg': '#eceff4',
'--gr-diff-gutter': '#4c566a',
'--gr-diff-gap-bg': '#3b4252',
},
} as const
type Palette = keyof typeof PALETTES
const palette = ref<Palette>('app')
const tokenizer = shallowRef<GrCodeTokenizer | null>(null)
const loading = ref(false)
/**
* Shiki грузит **потребитель**: движок регулярок и набор грамматик выбирает он,
* а пакет о Shiki не знает даже в импортах типов.
*/
async function loadShiki(): Promise<void> {
loading.value = true
const { createHighlighter } = await import('shiki')
const shiki = await createHighlighter({
langs: ['ts'],
// Тема-метка вместо настоящей: цвета Shiki нам не нужны, нужны роли.
themes: [GR_CODE_SHIKI_THEME],
})
// Приведение — плата за то, что пакет типизует Shiki структурно, по одному
// методу: у самого Shiki он объявлен через дженерики набора тем и языков.
// Ровно поэтому переименование метода в мажоре Shiki ломает эту строку и
// адаптер пакета, а контракт `GrCodeTokenizer` не ломает никогда.
tokenizer.value = createShikiTokenizer(shiki as unknown as ShikiLike)
loading.value = false
}
const style = computed(() => PALETTES[palette.value] ?? undefined)
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-3">
<GrButton size="sm" :loading="loading" :disabled="!!tokenizer" @click="loadShiki">
{{ tokenizer ? 'Shiki подключён' : 'Подключить Shiki' }}
</GrButton>
<GrSegmented
v-model="palette"
size="sm"
:options="[
{ value: 'app', label: 'Тема приложения' },
{ value: 'one-dark', label: 'One Dark' },
{ value: 'nord', label: 'Nord' },
]"
/>
</div>
<!-- Палитра — обычные CSS-переменные на обёртке: ниже её наследуют оба компонента. -->
<div class="grid gap-3" :style="style">
<GrCodeBlock
:code="SOURCE"
language="ts"
:highlighter="tokenizer ?? undefined"
aria-label="Пересчёт корзины"
line-numbers
/>
<GrDiff
:before="SOURCE"
:after="SOURCE.replace('discount > 0', 'discount > 0 && cart.items.length > 0')"
language="ts"
:highlighter="tokenizer ?? undefined"
:context="1"
/>
</div>
<p class="showcase-demo-text text-sm">
<template v-if="tokenizer">
Разбирает Shiki, цвет берут одиннадцать токенов — поэтому тема легла и на блок, и на дифф разом
</template>
<template v-else>
Пока Shiki не подключён, работает встроенный разбор: JSON и обычный текст
</template>
</p>
</div>
</template>Wrap
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSwitch } from '@feugene/granularity'
/** Строка лога, которая в колонку не помещается: типичный ответ шлюза. */
const LOG = `2026-08-31T10:12:04.881Z WARN gateway upstream=orders-api attempt=3 status=502 latency_ms=1841 trace=7f3a91c0b28d4e15 message="upstream returned bad gateway, retrying with backoff"
2026-08-31T10:12:06.204Z INFO gateway upstream=orders-api attempt=4 status=200 latency_ms=212 trace=7f3a91c0b28d4e15
2026-08-31T10:12:06.205Z INFO gateway request completed`
const wrap = ref(false)
const copyable = ref(true)
const copies = ref(0)
const wrapLabel = computed(() => wrap.value ? 'Перенос строк' : 'Горизонтальная прокрутка')
const copyLabel = computed(() => copyable.value ? 'Кнопка копирования есть' : 'Кнопка копирования убрана')
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSwitch v-model="wrap" size="sm">
{{ wrapLabel }}
</GrSwitch>
<GrSwitch v-model="copyable" size="sm">
{{ copyLabel }}
</GrSwitch>
</div>
<GrCodeBlock
:code="LOG"
language="text"
:wrap="wrap"
:copyable="copyable"
aria-label="Лог шлюза"
line-numbers
max-height="12rem"
@copy="copies += 1"
/>
<p class="showcase-demo-text text-sm">
Событие <code>copy</code> получено раз: <b>{{ copies }}</b>
</p>
</div>
</template>Accessibility
- APG pattern
блок (если скроллер) + кнопка