GrJsonViewer
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
| Need | Take |
|---|---|
| Read a text or a JSON in full and copy it at once | GrCodeBlock (the @feugene/granularity-code package) |
| A tree of your own data with selection and checkboxes | GrTree |
| The data is laid out in columns and known in advance | GrDataTable |
| A “property → value” pair | GrDescriptionList |
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…
<GrJsonViewer />Install
npm i @feugene/granularityImport
import { GrJsonViewer } from '@feugene/granularity/components/GrJsonViewer'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The name of the area for a screen reader. A nameless tree is announced simply as "tree". |
virtual | boolean | undefined | false | Keep in the DOM only the window around the viewport. It requires `maxHeight`. |
maxHeight | string | number | undefined | undefined | The height of the viewing area: a number is pixels, a string goes as it is. |
rootLabel | string | undefined | undefined | The label of the root. `$` by default — that is how a JSONPath and dev tools address a path. |
defaultExpandDepth | number | undefined | undefined | How many levels are expanded to begin with. |
maxStringLength | number | undefined | undefined | The 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. |
maxArrayItems | number | undefined | undefined | How many elements of an array to parse before the "N more" placeholder. |
searchable | boolean | undefined | undefined | A search field above the tree. Switched off — the search stays with `filter()` from `defineExpose`. |
copyable | boolean | undefined | undefined | A button that copies the node, in the row. |
valuerequired | unknown | — | The value being shown. `unknown`, because it comes from a database or from someone else’s service. |
Slots
| Slot | Type | Description |
|---|---|---|
leaf | { node: GrJsonNode; } | The value of a leaf: a link, a date, a sum of money — the styling is known by the application. |
Events
| Event | Type | Description |
|---|---|---|
copy | [payload: { path: string; value: unknown; }] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
filter | (value: string) => void | — |
expandAll | () => void | — |
collapseAll | () => void | — |
Examples 2
Response from a service, walked by node
<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
Строка с картинкой обрезана до 80 символов, массив — до 50 элементов с заглушкой «ещё». Копирование любого узла всё равно отдаёт значение целиком: обрезка принадлежит показу.
<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)