<script lang="ts"> import { onMount, createEventDispatcher, tick, afterUpdate } from "svelte"; export let value: any; export let depth = 0; export let is_root = false; export let is_last_item = true; export let key: string | number | null = null; export let open = false; export let theme_mode: "system" | "light" | "dark" = "system"; export let show_indices = false; const dispatch = createEventDispatcher(); let root_element: HTMLElement; let collapsed = open ? false : depth >= 3; let child_nodes: any[] = []; function is_collapsible(val: any): boolean { return val !== null && (typeof val === "object" || Array.isArray(val)); } async function toggle_collapse(): Promise<void> { collapsed = !collapsed; await tick(); dispatch("toggle", { collapsed, depth }); } function get_collapsed_preview(val: any): string { if (Array.isArray(val)) return `Array(${val.length})`; if (typeof val === "object" && val !== null) return `Object(${Object.keys(val).length})`; return String(val); } $: if (is_collapsible(value)) { child_nodes = Object.entries(value); } else { child_nodes = []; } $: if (is_root && root_element) { updateLineNumbers(); } function updateLineNumbers(): void { const lines = root_element.querySelectorAll(".line"); lines.forEach((line, index) => { const line_number = line.querySelector(".line-number"); if (line_number) { line_number.setAttribute("data-pseudo-content", (index + 1).toString()); line_number?.setAttribute( "aria-roledescription", `Line number ${index + 1}` ); line_number?.setAttribute("title", `Line number ${index + 1}`); } }); } onMount(() => { if (is_root) { updateLineNumbers(); } }); afterUpdate(() => { if (is_root) { updateLineNumbers(); } }); </script> <div class="json-node" class:root={is_root} class:dark-mode={theme_mode === "dark"} bind:this={root_element} on:toggle style="--depth: {depth};" > <div class="line" class:collapsed> <span class="line-number"></span> <span class="content"> {#if is_collapsible(value)} <button data-pseudo-content={collapsed ? "▶" : "▼"} aria-label={collapsed ? "Expand" : "Collapse"} class="toggle" on:click={toggle_collapse} /> {/if} {#if key !== null} <span class="key">"{key}"</span><span class="punctuation colon" >: </span> {/if} {#if is_collapsible(value)} <span class="punctuation bracket" class:square-bracket={Array.isArray(value)} >{Array.isArray(value) ? "[" : "{"}</span > {#if collapsed} <button on:click={toggle_collapse} class="preview"> {get_collapsed_preview(value)} </button> <span class="punctuation bracket" class:square-bracket={Array.isArray(value)} >{Array.isArray(value) ? "]" : "}"}</span > {/if} {:else if typeof value === "string"} <span class="value string">"{value}"</span> {:else if typeof value === "number"} <span class="value number">{value}</span> {:else if typeof value === "boolean"} <span class="value bool">{value.toString()}</span> {:else if value === null} <span class="value null">null</span> {:else} <span>{value}</span> {/if} {#if !is_last_item && (!is_collapsible(value) || collapsed)} <span class="punctuation">,</span> {/if} </span> </div> {#if is_collapsible(value)} <div class="children" class:hidden={collapsed}> {#each child_nodes as [subKey, subVal], i} <svelte:self value={subVal} depth={depth + 1} is_last_item={i === child_nodes.length - 1} key={subKey} {open} {theme_mode} {show_indices} on:toggle /> {/each} <div class="line"> <span class="line-number"></span> <span class="content"> <span class="punctuation bracket" class:square-bracket={Array.isArray(value)} >{Array.isArray(value) ? "]" : "}"}</span > {#if !is_last_item}<span class="punctuation">,</span>{/if} </span> </div> </div> {/if} </div> <style> .json-node { font-family: var(--font-mono); --text-color: #d18770; --key-color: var(--text-color); --string-color: #ce9178; --number-color: #719fad; --bracket-color: #5d8585; --square-bracket-color: #be6069; --punctuation-color: #8fbcbb; --line-number-color: #6a737d; --separator-color: var(--line-number-color); } .json-node.dark-mode { --bracket-color: #7eb4b3; --number-color: #638d9a; } .json-node.root { position: relative; padding-left: var(--size-14); } .json-node.root::before { content: ""; position: absolute; top: 0; bottom: 0; left: var(--size-11); width: 1px; background-color: var(--separator-color); } .line { display: flex; align-items: flex-start; padding: 0; margin: 0; line-height: var(--line-md); } .line-number { position: absolute; left: 0; width: calc(var(--size-7)); text-align: right; color: var(--line-number-color); user-select: none; text-overflow: ellipsis; text-overflow: ellipsis; direction: rtl; overflow: hidden; } .content { flex: 1; display: flex; align-items: center; padding-left: calc(var(--depth) * var(--size-2)); flex-wrap: wrap; } .children { padding-left: var(--size-4); } .children.hidden { display: none; } .key { color: var(--key-color); } .string { color: var(--string-color); } .number { color: var(--number-color); } .bool { color: var(--text-color); } .null { color: var(--text-color); } .value { margin-left: var(--spacing-md); } .punctuation { color: var(--punctuation-color); } .bracket { margin-left: var(--spacing-sm); color: var(--bracket-color); } .square-bracket { margin-left: var(--spacing-sm); color: var(--square-bracket-color); } .toggle, .preview { background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin: 0; } .toggle { user-select: none; margin-right: var(--spacing-md); } .preview { margin: 0 var(--spacing-sm) 0 var(--spacing-lg); } .preview:hover { text-decoration: underline; } :global([data-pseudo-content])::before { content: attr(data-pseudo-content); } </style>