GrJsonViewer

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда ответ чужого сервиса разбирают по полям.

Когда брать

  • ответ чужого сервиса разбирают по полям — вебхук, ответ модели, тело ошибки: нужно найти ключ, а не прочитать документ целиком;
  • структура заранее неизвестна — форма приходит от бэкенда и меняется, и раскладывать её по колонкам не по чему;
  • значение нужно достать точечно — копируется узел вместе с путём, а не вся простыня;
  • данных много — свёртка, поиск и виртуализация держат ответ на тысячи узлов.

Когда взять другое

НужноБерите
Прочитать текст или JSON целиком и скопировать разомGrCodeBlock (пакет @feugene/granularity-code)
Дерево своих данных с выбором и чекбоксамиGrTree
Данные разложены по колонкам и известны заранееGrDataTable
Пара «характеристика → значение»GrDescriptionList

Показ обрезается, копирование — нет

Длинное строковое значение обрезается в строке (maxStringLength), длинный массив обрывается заглушкой «ещё N» (maxArrayItems). Это не косметика: запрос к модели с картинкой в base64 — один лист на сотни тысяч символов, и ни свёртка по узлам, ни виртуализация по строкам его не берут, потому что узел там один и строка одна.

В буфер при этом уходит полное значение узла вместе с путём: обрезка принадлежит показу, и вставить её обратно нельзя.

Повторная ссылка — не цикл

Маркер [Circular] достаётся только настоящему предку по цепочке, а объект, честно положенный в данные дважды, рисуется дважды.

Это тот случай, где обход дерева умеет строго больше сериализации: replacer у JSON.stringify стека предков не получает и вынужден метить любую повторную ссылку. Обход стек имеет, поэтому у GrCodeBlock и GrJsonViewer на одних и тех же данных разный — и каждый по-своему правильный — результат.

Адрес узла читается

Ключом узла служит путь ($.items[3].name), а не порядковый номер: он уходит в событие copy, по нему же задаётся раскрытие. Ключ с точкой или пробелом экранируется ($["a.b"]), иначе адрес перестал бы быть адресом.

Раскрытие задаётся глубиной, а не списком

defaultExpandDepth раскрывает первые N уровней; expandAll() и collapseAll() из defineExpose переключают всё дерево. Обе кнопки сбрасывают ручное раскрытие пользователя — от «раскрыть всё» этого и ждут.

Поиск идёт по ключу и по значению

В чужом ответе ищут то одно, то другое, поэтому предикат смотрит и на имя ключа, и на показанное значение. Совпавшие узлы дерево подсвечивает целиком и раскрывает к ним путь.

Поле поиска можно убрать (searchable: false) и звать filter(query) снаружи — когда строка поиска на странице уже есть и вторая была бы лишней.

Границы

Не редактирует и не диффит: просмотрщик показывает то, что пришло. Подсветки совпавшей подстроки внутри строки нет — дерево отмечает узел целиком. Виртуализация включается только вместе с maxHeight (GrTree): без высоты окно считать не от чего.

Playground 9

Загружается…

Код
<GrJsonViewer />

Установка

npm i @feugene/granularity

Импорт

import { GrJsonViewer } from '@feugene/granularity/components/GrJsonViewer'

API

Props

PropTypeпо умолчаниюОписание
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedИмя области для скринридера. Безымянное дерево объявляется просто «дерево».
virtualboolean | undefinedfalseОставлять в DOM только окно вокруг вьюпорта. Требует `maxHeight`.
maxHeightstring | number | undefinedundefinedВысота области просмотра: число — пиксели, строка — как есть.
rootLabelstring | undefinedundefinedПодпись корня. По умолчанию `$` — так адресуют путь JSONPath и dev-tools.
defaultExpandDepthnumber | undefinedundefinedСколько уровней раскрыто изначально.
maxStringLengthnumber | undefinedundefinedДлина строкового значения, после которой показ обрезается. Не косметика: запрос к модели с картинкой в base64 — это один лист на сотни тысяч символов, и ни свёртка, ни виртуализация по строкам его не берут. Копирование при этом отдаёт значение целиком.
maxArrayItemsnumber | undefinedundefinedСколько элементов массива разбирать до заглушки «ещё N».
searchableboolean | undefinedundefinedПоле поиска над деревом. Выключено — поиск остаётся на `filter()` из `defineExpose`.
copyableboolean | undefinedundefinedКнопка копирования узла в строке.
valueобязательныйunknownПоказываемое значение. `unknown`, потому что приходит из БД или чужого сервиса.

Slots

SlotTypeОписание
leaf{ node: GrJsonNode; }Значение листа: ссылка, дата, денежная сумма — оформление знает приложение.

Events

EventTypeОписание
copy[payload: { path: string; value: unknown; }]

Methods / Expose

Methods / ExposeTypeОписание
filter(value: string) => void
expandAll() => void
collapseAll() => void

Примеры 2

Ответ сервиса, разобранный по узлам

Корень раскрыт, остальное свёрнуто: сначала видно форму ответа, а не его объём. Поиск идёт и по ключу, и по значению — в чужом ответе ищут то одно, то другое.

$:{8}
id:"run_01HXQZ8K3M7N2P4R6T8V0W1Y3Z"
model:"gpt-4o-mini"
cached:false
finished_at:null

Response
<script setup lang="ts">
import { GrJsonViewer } from '@feugene/granularity'

// Ровно та форма, в которой ответ приходит из БД: `unknown` со всеми типами
// JSON, включая `null` и вложенный массив.
const response = {
  id: 'run_01HXQZ8K3M7N2P4R6T8V0W1Y3Z',
  model: 'gpt-4o-mini',
  cached: false,
  finished_at: null,
  usage: { prompt_tokens: 1284, completion_tokens: 96, total_tokens: 1380 },
  store: { name: 'Пятёрочка', inn: '7728029110', address: 'Москва, Ленинский проспект, 12' },
  items: [
    { name: 'Молоко 3.2%', qty: 2, price: 89.9, sum: 179.8 },
    { name: 'Хлеб бородинский', qty: 1, price: 54.5, sum: 54.5 },
    { name: 'Кофе зерновой 1 кг', qty: 1, price: 1249, sum: 1249 },
  ],
  totals: { subtotal: 1483.3, discount: 74.15, total: 1409.15 },
}
</script>

<template>
  <GrJsonViewer :value="response" max-height="22rem" aria-label="Ответ модели" />
</template>

Ключ узла — читаемый путь ($.items[2].name), а не порядковый номер: он уходит в событие copy, по нему же задаётся раскрытие.

Картинка в base64 и пять тысяч элементов

Два крайних случая в одном значении: один строковый лист на сотни тысяч символов и массив на пять тысяч узлов. Первый не берёт ни свёртка, ни виртуализация — узел там один, поэтому его режет maxStringLength; второй режет maxArrayItems и виртуализация.

$:{3}
model:"gemini-3.1-pro"

Строка с картинкой обрезана до 80 символов, массив — до 50 элементов с заглушкой «ещё». Копирование любого узла всё равно отдаёт значение целиком: обрезка принадлежит показу.

Limits
<script setup lang="ts">
import { GrJsonViewer } from '@feugene/granularity'

/**
 * Не выдуманный крайний случай, а форма запроса к модели с картинкой: провайдер
 * кладёт изображение в base64 прямо в тело, и это **один** строковый лист на
 * сотни тысяч символов. Свёртка по узлам его не берёт — узел там один.
 */
const request = {
  model: 'gemini-3.1-pro',
  contents: [
    {
      role: 'user',
      parts: [
        { text: 'Разбери чек и верни JSON по схеме.' },
        { inline_data: { mime_type: 'image/jpeg', data: `data:image/jpeg;base64,${'R0lGODlhAQABAIAAAAUEBA'.repeat(2000)}` } },
      ],
    },
  ],
  // Массив на пять тысяч — вторая крайность: узлов много, каждый крошечный.
  candidates: Array.from({ length: 5000 }, (_, index) => ({ index, logprob: -0.0001 * index })),
}
</script>

<template>
  <div class="grid gap-3">
    <GrJsonViewer
      :value="request"
      :max-string-length="80"
      :max-array-items="50"
      virtual
      max-height="20rem"
      aria-label="Запрос к модели"
    />

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Строка с картинкой обрезана до 80 символов, массив — до 50 элементов с заглушкой «ещё».
      Копирование любого узла всё равно отдаёт значение целиком: обрезка принадлежит показу.
    </p>
  </div>
</template>

Копирование при этом отдаёт значение целиком: обрезка принадлежит показу, и вставить её обратно нельзя.

Доступность

Паттерн APG
tree (через GrTree)
Клавиши
своих клавиш нет: дерево внутри — GrTree, и весь его контракт действует без изменений. Поле поиска и кнопки свёртки над деревом — обычные контролы, каждый со своей остановкой Tab

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты