GrCodeBlock
Берут, когда показать ответ сервиса как есть.
Когда брать
- показать ответ сервиса как есть — тело запроса, ответ модели, payload
события: значение приходит
unknownи структура заранее неизвестна; - это копируют — в тикет, в чат поддержки; кнопка копирует исходный текст, а не то, что видно на экране;
- значение техническое — идентификаторы, хеши, конфиг: моноширинный шрифт и подсветка отвечают «это данные, а не проза»;
- ответ длинный —
maxHeightпревращает блок в скроллер, достижимый с клавиатуры.
Когда взять другое
| Нужно | Берите |
|---|---|
| Код правят, а не читают | GrCodeEditor |
| Сравнить две версии | GrDiff |
| Разворачивать и сворачивать узлы данных | GrJsonViewer или GrTree (ядро) |
| Свернуть блок целиком | GrCollapse (ядро) |
| Пара «характеристика → значение» | GrDescriptionList (ядро) |
| Клавиша или сочетание в тексте | GrKbd (ядро) |
| Значение — обычный текст, а не код | GrTextarea (ядро) |
Сериализация не имеет права уронить страницу
code принимает unknown, потому что данные приходят из БД и бывают любыми.
Отсюда три решения, каждое из которых закрывает реальный отказ:
- циклическая ссылка заменяется маркером
[Circular], а не вешает вкладку. Маркер получает и объект, встреченный второй раз в разных ветках: отличить повтор от настоящего цикла можно только стеком предков, аreplacerего не отдаёт. Неточность здесь дешевле зависания; BigIntпечатается с суффиксомn— штатныйJSON.stringifyна нём бросает;- всё остальное, что упало при сериализации (враждебный
toJSON), даёт[Unserializable].
Строка проходит как есть, без кавычек и переформатирования: это уже готовый
текст. undefined печатает пустой блок, null — литерал null; «данных нет»
и «значение равно null» это разные утверждения, и решать между ними — задача
страницы, а не блока.
Копируется исходник, а не экран
Кнопка кладёт в буфер ту же строку, что отрисована, — но взятую из модели, а не из разметки. Разница видна с номерами строк: скопировать вместе с ними значит получить текст, который некуда вставить.
Номера строк для этого и сделаны CSS-счётчиком: как текста их в разметке нет вовсе, поэтому они не попадают ни в буфер, ни в выделение мышью.
Без защищённого контекста кнопки нет. navigator.clipboard недоступен по
http://, и кнопка, которая молча ничего не делает, хуже её отсутствия.
Наличие буфера проверяется после монтирования — в первом рендере кнопки нет ни
на сервере, ни на клиенте, поэтому гидрация совпадает.
Успех уходит в живой регион (useAnnouncer) и в событие copy — тост показывает
потребитель: своего места для него у блока нет.
Кнопка стоит рядом со скроллером, а не поверх него. Полосу прокрутки браузер
рисует у правого края <pre>, и накрывшая её кнопка отбирает те самые пиксели,
за которые полосу хватают мышью. Поэтому блок резервирует справа жёлоб, а сам
код сужается на его ширину; отступами это не решается — их пришлось бы взять
больше ширины кнопки, и угол перестал бы быть углом. Жёлоба нет, когда нет
кнопки: при copyable: false и без защищённого контекста ширина не теряется.
Скроллер и клавиатура
Блок встаёт в таб-порядок, когда он скроллер по пропам: задан maxHeight
либо выключен wrap (тогда длинная строка даёт горизонтальную прокрутку).
Замер переполнения не используется намеренно: он делал бы остановку Tab
мигающей на каждой смене данных и на загрузке шрифта.
ariaLabel даёт области role="region" и имя. Безымянную область скринридер
объявляет просто «регион», и на странице с четырьмя блоками их не различить.
Подсветка своя и только для JSON
Токенизация — чистая функция на четыре роли: ключ, строка, число, литерал.
Тянуть highlight.js ради этого несоразмерно, а language="text" отключает
разбор совсем.
Цвета — ссылки на роли темы, поэтому подсветка работает в светлой и тёмной без
своего theme-слоя. Число взято от azure, а не от info: info — синий в двух
шагах от индиго primary, и пара «ключ ↔ число» сливалась бы в {"count": 42}.
Контраст и различимость ролей проверяются тестом, а не глазом.
Границы
Не редактирует, не диффит и не подсвечивает языки кроме JSON. Узлы он тоже не
сворачивает — это GrJsonViewer (ядро), и различитель между ними
простой: текст читают целиком или в нём ищут поле.
Установка
npm i @feugene/granularity-codeИмпорт
import { GrCodeBlock } from '@feugene/granularity-code/components/GrCodeBlock'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 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>Доступность
- Паттерн APG
блок (если скроллер) + кнопка- Клавиши
- своих нет. Блок встаёт в таб-порядок (
tabindex="0"), когда он скроллер **по пропам** — заданmaxHeightлибо выключенwrap; листается стрелками иPageUp/PageDown. Кнопка копирования — обычная кнопка, своя остановка