GrJsonViewer

Package: @feugene/granularitycoreGroup: data

Walks an unknown value as a tree: collapsible nodes, search by key or value, copy a node with its path.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the answer of someone else’s service is picked apart field by field — a webhook, the answer of a model, the body of an error: a key has to be found, not the whole document read;
  • the structure is not known in advance — the shape arrives from the backend and changes, and there is nothing to lay it out into columns by;
  • a value has to be taken out pointwise — a node is copied together with its path, not the whole sheet;
  • there is a lot of data — collapsing, search and virtualisation hold an answer of thousands of nodes.

When to take something else

NeedTake
Read a text or a JSON in full and copy it at onceGrCodeBlock (the @feugene/granularity-code package)
A tree of your own data with selection and checkboxesGrTree
The data is laid out in columns and known in advanceGrDataTable
A “property → value” pairGrDescriptionList

The display is truncated, the copying is not

A long string value is truncated in the row (maxStringLength), and a long array is cut off with a “N more” placeholder (maxArrayItems). That is not cosmetics: a request to a model with a picture in base64 is one leaf of hundreds of thousands of characters, and neither collapsing by node nor virtualisation by row takes it, because there is one node there and one row.

The full value of the node goes into the clipboard together with the path: the truncation belongs to the display, and pasting it back is not possible.

A repeated reference is not a cycle

The [Circular] marker goes only to a real ancestor along the chain, and an object honestly put into the data twice is drawn twice.

This is the case where walking a tree can strictly more than serialisation: the replacer of JSON.stringify does not receive the stack of ancestors and is forced to mark any repeated reference. A walk has the stack, so GrCodeBlock and GrJsonViewer give different — and each in its own way correct — results on the same data.

The address of a node is readable

The key of a node is its path ($.items[3].name) rather than an ordinal number: it leaves in the copy event, and the expansion is set by it as well. A key with a dot or a space is escaped ($["a.b"]), otherwise the address would stop being an address.

The expansion is set by depth, not by a list

defaultExpandDepth expands the first N levels; expandAll() and collapseAll() from defineExpose switch the whole tree. Both buttons reset the user’s manual expansion — that is what is expected of “expand everything”.

The search runs over the key and the value

In someone else’s answer people look now for one, now for the other, so the predicate looks both at the name of the key and at the displayed value. The matching nodes are highlighted by the tree as a whole, and the path to them is expanded.

The search field can be removed (searchable: false) and filter(query) called from the outside — when a search bar already exists on the page and a second one would be redundant.

Limits

It does not edit and does not diff: the viewer shows what has arrived. There is no highlighting of the matched substring inside a string — the tree marks the node as a whole. The virtualisation switches on only together with maxHeight (GrTree): without a height there is nothing to compute the window from.

Playground 9

Loading…

Code
<GrJsonViewer />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedThe name of the area for a screen reader. A nameless tree is announced simply as "tree".
virtualboolean | undefinedfalseKeep in the DOM only the window around the viewport. It requires `maxHeight`.
maxHeightstring | number | undefinedundefinedThe height of the viewing area: a number is pixels, a string goes as it is.
rootLabelstring | undefinedundefinedThe label of the root. `$` by default — that is how a JSONPath and dev tools address a path.
defaultExpandDepthnumber | undefinedundefinedHow many levels are expanded to begin with.
maxStringLengthnumber | undefinedundefinedThe length of a string value beyond which the display is truncated. Not cosmetics: a request to a model with a picture in base64 is one leaf of hundreds of thousands of characters, and neither collapsing nor virtualisation by row takes it. The copying meanwhile gives away the value in full.
maxArrayItemsnumber | undefinedundefinedHow many elements of an array to parse before the "N more" placeholder.
searchableboolean | undefinedundefinedA search field above the tree. Switched off — the search stays with `filter()` from `defineExpose`.
copyableboolean | undefinedundefinedA button that copies the node, in the row.
valuerequiredunknownThe value being shown. `unknown`, because it comes from a database or from someone else’s service.

Slots

SlotTypeDescription
leaf{ node: GrJsonNode; }The value of a leaf: a link, a date, a sum of money — the styling is known by the application.

Events

EventTypeDescription
copy[payload: { path: string; value: unknown; }]

Methods / Expose

Methods / ExposeTypeDescription
filter(value: string) => void
expandAll() => void
collapseAll() => void

Examples 2

Response from a service, walked by node

$:{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>

A base64 image and five thousand items

$:{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>

Accessibility

APG pattern
tree (через GrTree)

Full keyboard contract of the package

Component documentationAll components