JSON viewer
Any value as a tree you can open and close, with each kind of value in its own colour and a count on every closed branch.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add json-viewerpnpm dlx ermineui add json-vieweryarn dlx ermineui add json-viewerbunx ermineui add json-viewerThat copies 3 files into src/components/ui/json-viewer/. Files you already have are kept.
Use
import { JsonViewer } from '@/components/ui/json-viewer'<JsonViewer :value="payload" :expand-depth="2" />
<DiffViewer :before="oldText" :after="newText" view="split" title="config.ts" />
<MediaPlayer title="Product tour" src="/tour.mp4" poster="/tour.jpg">
<track kind="captions" src="/tour.vtt" srclang="en" label="English" />
</MediaPlayer>
<Marquee label="Customers"><img v-for="logo in logos" :key="logo.name" :src="logo.src" :alt="logo.name" /></Marquee>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
JsonNode.vue | src/components/ui/json-viewer/JsonNode.vue |
JsonViewer.vue | src/components/ui/json-viewer/JsonViewer.vue |
index.ts | src/components/ui/json-viewer/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
JsonNode.vue 59 lines
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
value: unknown
/** The key this value sits under. Items of an array have none. */
name?: string
depth: number
expandDepth: number
/** The last one in its parent, so no comma follows. */
last?: boolean
}>()
const type = computed(() => {
const value = props.value
if (value === null || value === undefined) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value === 'object' ? 'object' : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? typeof value : 'string'
})
const branch = computed(() => type.value === 'array' || type.value === 'object')
const entries = computed<Array<[string | undefined, unknown]>>(() => {
if (type.value === 'array') return (props.value as unknown[]).map((item) => [undefined, item])
if (type.value === 'object') return Object.entries(props.value as Record<string, unknown>)
return []
})
const brackets = computed(() => (type.value === 'array' ? ['[', ']'] : ['{', '}']))
const count = computed(() => `${entries.value.length} ${type.value === 'array' ? (entries.value.length === 1 ? 'item' : 'items') : entries.value.length === 1 ? 'key' : 'keys'}`)
const open = ref(props.depth < props.expandDepth)
</script>
<template>
<li class="er-json-node">
<template v-if="branch && entries.length">
<button type="button" class="er-json-node-toggle" :aria-expanded="open" @click="open = !open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
<span v-if="name !== undefined" class="er-json-node-key">{{ JSON.stringify(name) }}<span class="er-json-node-punctuation">: </span></span>
<span class="er-json-node-punctuation">{{ brackets[0] }}</span>
<template v-if="!open">
<span class="er-json-node-count">{{ count }}</span>
<span class="er-json-node-punctuation">{{ brackets[1] }}{{ last ? '' : ',' }}</span>
</template>
</button>
<template v-if="open">
<ul>
<JsonNode v-for="([key, item], index) in entries" :key="key ?? index" :value="item" :name="key" :depth="depth + 1" :expand-depth="expandDepth" :last="index === entries.length - 1" />
</ul>
<span class="er-json-node-punctuation">{{ brackets[1] }}{{ last ? '' : ',' }}</span>
</template>
</template>
<template v-else>
<span v-if="name !== undefined" class="er-json-node-key">{{ JSON.stringify(name) }}<span class="er-json-node-punctuation">: </span></span>
<span v-if="branch" class="er-json-node-punctuation">{{ brackets.join('') }}</span>
<span v-else class="er-json-node-value" :data-type="type">{{ type === 'null' ? 'null' : JSON.stringify(value) }}</span>
<span v-if="!last" class="er-json-node-punctuation">,</span>
</template>
</li>
</template>JsonViewer.vue 127 lines
<script setup lang="ts">
import JsonNode from './JsonNode.vue'
withDefaults(
defineProps<{
/** Anything JSON can hold: an object, an array, text, a number, true, false or null. */
value: unknown
/** How many levels start open. 0 shows only the outermost brackets. */
expandDepth?: number
/** Accessible name of the tree. */
label?: string
/** What the outermost value is called. Leave out to show no name for it. */
rootName?: string
}>(),
{ expandDepth: 1, label: 'JSON' },
)
</script>
<template>
<div class="er-json-viewer" role="group" :aria-label="label">
<ul>
<JsonNode :value="value" :name="rootName" :depth="0" :expand-depth="expandDepth" last />
</ul>
</div>
</template>
<style>
@layer components {
.er-json-viewer {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-card);
color: var(--er-card-foreground);
font-family: var(--er-font-mono);
font-size: 0.8125rem;
line-height: 1.5rem;
overflow-x: auto;
}
.er-json-viewer ul {
margin: 0;
padding: 0;
list-style: none;
}
.er-json-viewer ul ul {
margin-left: 0.4375rem;
padding-left: 1rem;
border-left: 1px solid var(--er-border);
}
.er-json-viewer li {
white-space: nowrap;
}
.er-json-node-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: -1.125rem;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
/* The outermost value has nothing to its left to hang the arrow in. */
.er-json-viewer > ul > li > .er-json-node-toggle {
margin-left: 0;
}
.er-json-node-toggle:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 1px;
}
.er-json-node-toggle svg {
width: 0.875rem;
height: 0.875rem;
color: var(--er-muted-foreground);
transition: transform var(--er-duration);
}
.er-json-node-toggle[aria-expanded='true'] svg {
transform: rotate(90deg);
}
.er-json-node-key {
color: var(--er-foreground);
}
.er-json-node-punctuation,
.er-json-node-count {
color: var(--er-muted-foreground);
}
.er-json-node-count {
font-style: italic;
}
/* Each kind of value has its own colour, and its own shape as text: quotes, digits, words. */
.er-json-node-value[data-type='string'] {
color: var(--er-success);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.er-json-node-value[data-type='number'] {
color: var(--er-info);
}
/* The warning colour alone is too pale to read as text, so it is darkened towards the text colour. */
.er-json-node-value[data-type='boolean'] {
color: color-mix(in oklab, var(--er-warning) 65%, var(--er-foreground));
}
.er-json-node-value[data-type='null'] {
color: var(--er-muted-foreground);
}
}
</style>index.ts 3 lines
/** JSON viewer — any value as a tree you can open and close, with each kind of value in its own colour and a count on every closed branch. */
export { default as JsonViewer } from './JsonViewer.vue'