Zum Inhalt springen
UI Kit
Accent

JSON-Ansicht

JSON, das man lesen kann. Ein Baum, der sich per Tastatur öffnet und schließt, Anzahlen an geschlossenen Zweigen, Werte nach Typ getönt, und ein Klick auf einen Schlüssel kopiert seinen Pfad.

Eine von 2 Components in Code und JSON

4 Variants

Variants

Baum

Zwei Ebenen offen zum Start, Pfeile öffnen den Rest, Leitlinien zeigen die Tiefe. Alle öffnen, alle schließen und kopieren stehen in der Leiste, der Pfad eines angeklickten Schlüssels steht darunter.

Im Studio öffnen

Tabelle

Ein Array aus Objekten als Tabelle mit einer Spalte je Schlüssel, ein Umschalter zurück zum Baum. Verschachteltes zeigt seine Größe, Fehlendes einen Punkt.

Im Studio öffnen

API-Antwort

Die Antwort einer Anfrage mit Methode, Pfad, Status, Zeit und Größe, der Inhalt als Baum und die Header im zweiten Reiter. Das Statuslicht pulsiert beim Eintreffen zweimal.

Im Studio öffnen

Details

Was JSON-Ansicht mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Code und JSON
Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/json-viewer.tsx
Bringt mit
utilskiticoncopy-button
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 21 eigene Texte

Props

Die Schnittstelle JsonViewerProps aus components/ui/json-viewer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    JsonViewerVariant

    treetablesearchresponse

  • dataPflicht
    Json | string

    The data, as a value or as JSON text

  • labeloptional
    string

    Accessible name and the title above

  • depthoptional
    number

    Levels open at the start. Default 2.

  • defaultQueryoptional
    string

    search: a query at the start, for demos

  • requestoptional
    string

    response: the request line, e.g. "GET /v1/orders/4821"

  • statusoptional
    number

    response: HTTP status code

  • statusTextoptional
    string

    response: status text, e.g. "OK"

  • durationoptional
    number

    response: duration in ms

  • headersoptional
    Record<string, string>

    response: response headers

Eingebaute Texte

Diese Texte schreibt JSON-Ansicht selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 21 Texte anzeigen
jsonEnglisch JSON dataDeutsch JSON-Daten
itemsEnglisch itemsDeutsch Einträge
keysEnglisch keysDeutsch Schlüssel
expandAllEnglisch Open allDeutsch Alle öffnen
collapseAllEnglisch Close allDeutsch Alle schließen
copyEnglisch Copy JSONDeutsch JSON kopieren
copiedEnglisch CopiedDeutsch Kopiert
copyPathEnglisch Copy pathDeutsch Pfad kopieren
pathCopiedEnglisch Path copiedDeutsch Pfad kopiert
keyHintEnglisch Click a key to copy its pathDeutsch Einen Schlüssel anklicken kopiert seinen Pfad
viewEnglisch ViewDeutsch Ansicht
searchEnglisch Search keys and valuesDeutsch Schlüssel und Werte suchen
matchesEnglisch matchesDeutsch Treffer
noMatchEnglisch Nothing matches. Try a key name or a value.Deutsch Nichts gefunden. Versuch einen Schlüssel oder einen Wert.
tableEnglisch TableDeutsch Tabelle
rawEnglisch TreeDeutsch Baum
bodyEnglisch BodyDeutsch Inhalt
headersEnglisch HeadersDeutsch Header
statusEnglisch StatusDeutsch Status
timeEnglisch TimeDeutsch Zeit
sizeEnglisch SizeDeutsch Größe

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • JSON-Ansicht reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-expanded, aria-label, aria-level, aria-live, aria-posinset und aria-setsize, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio, radiogroup, region, tree und treeitem.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 531 Zeilen
components/ui/json-viewer.tsx
'use client';

import { useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { useCopy } from '@/components/ui/copy-button';

/*
 * JSON you can read: a tree that opens and closes with the tree pattern of
 * the APG (arrows, Home, End, * opens siblings), values tinted by type in
 * the accent of the project, counts on closed branches, a click on a key
 * copies its path ($.order.items[0].price). Four looks: the tree, an array
 * as a table, the tree with search that opens the matching paths, and an API
 * response with status, timing and headers. One typeface, no monospace:
 * digits are tabular and the indent is a fixed width per level.
 */

const TEXTS = {
  en: {
    json: 'JSON data', items: 'items', keys: 'keys', expandAll: 'Open all', collapseAll: 'Close all', copy: 'Copy JSON', copied: 'Copied', copyPath: 'Copy path', pathCopied: 'Path copied', keyHint: 'Click a key to copy its path', view: 'View',
    search: 'Search keys and values', matches: 'matches', noMatch: 'Nothing matches. Try a key name or a value.', table: 'Table', raw: 'Tree', body: 'Body', headers: 'Headers', status: 'Status', time: 'Time', size: 'Size',
  },
  de: {
    json: 'JSON-Daten', items: 'Einträge', keys: 'Schlüssel', expandAll: 'Alle öffnen', collapseAll: 'Alle schließen', copy: 'JSON kopieren', copied: 'Kopiert', copyPath: 'Pfad kopieren', pathCopied: 'Pfad kopiert', keyHint: 'Einen Schlüssel anklicken kopiert seinen Pfad', view: 'Ansicht',
    search: 'Schlüssel und Werte suchen', matches: 'Treffer', noMatch: 'Nichts gefunden. Versuch einen Schlüssel oder einen Wert.', table: 'Tabelle', raw: 'Baum', body: 'Inhalt', headers: 'Header', status: 'Status', time: 'Zeit', size: 'Größe',
  },
};
type Texts = (typeof TEXTS)['en'];

type Json = string | number | boolean | null | Json[] | { [k: string]: Json };

const isBranch = (v: Json): v is Json[] | { [k: string]: Json } => v !== null && typeof v === 'object';
const childPath = (path: string, key: string | number) => (typeof key === 'number' ? `${path}[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}["${key}"]`);
const entries = (v: Json[] | { [k: string]: Json }): [string | number, Json][] => (Array.isArray(v) ? v.map((x, i) => [i, x]) : Object.entries(v));

/** Every branch path, for open all */
function branchPaths(v: Json, path = '$', out: string[] = []) {
  if (isBranch(v)) {
    out.push(path);
    for (const [k, c] of entries(v)) branchPaths(c, childPath(path, k), out);
  }
  return out;
}

/** Paths of the branches that hold a match, so the search can open them */
function searchPaths(v: Json, q: string, path = '$', key: string | number = '', hits = new Set<string>(), open = new Set<string>()): { hits: Set<string>; open: Set<string> } {
  const matchKey = typeof key === 'string' && key.toLowerCase().includes(q);
  const matchVal = !isBranch(v) && String(v).toLowerCase().includes(q);
  if (q && (matchKey || matchVal)) {
    hits.add(path);
    // open every parent on the way
    let p = path;
    while (p.length > 1) {
      p = p.replace(/(\.[^.[\]]+|\[[^\]]+\])$/, '');
      open.add(p || '$');
    }
  }
  if (isBranch(v)) for (const [k, c] of entries(v)) searchPaths(c, q, childPath(path, k), k, hits, open);
  return { hits, open };
}

function ValueText({ v }: { v: Json }) {
  if (v === null) return <span className="italic text-subtle-foreground">null</span>;
  if (typeof v === 'string') return <span className="break-all text-[color-mix(in_oklab,var(--primary)_42%,var(--muted-foreground))]">&quot;{v}&quot;</span>;
  if (typeof v === 'number') return <span className="tabular-nums text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{v}</span>;
  if (typeof v === 'boolean') return <span className="font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{String(v)}</span>;
  return null;
}

/** Marks a search hit inside a text */
function Mark({ text, q }: { text: string; q: string }) {
  if (!q) return <>{text}</>;
  const i = text.toLowerCase().indexOf(q);
  if (i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--warning)_45%,transparent)] text-inherit">{text.slice(i, i + q.length)}</mark>
      {text.slice(i + q.length)}
    </>
  );
}

interface TreeProps {
  data: Json;
  t: Texts;
  open: Set<string>;
  setOpen: (s: Set<string>) => void;
  q?: string;
  hits?: Set<string>;
  label?: string;
  onCopyPath?: (path: string) => void;
}

/** The tree itself, with roving focus over the visible rows */
function JsonTree({ data, t, open, setOpen, q = '', hits, label, onCopyPath }: TreeProps) {
  const base = useId();
  const [focus, setFocus] = useState('$');
  // The visible rows in order, for the keyboard
  const rows = useMemo(() => {
    const out: { path: string; parent: string | null; branch: boolean }[] = [];
    const walk = (v: Json, path: string, parent: string | null) => {
      out.push({ path, parent, branch: isBranch(v) });
      if (isBranch(v) && open.has(path)) for (const [k, c] of entries(v)) walk(c, childPath(path, k), path);
    };
    walk(data, '$', null);
    return out;
  }, [data, open]);
  const idOf = (path: string) => `${base}-${path.replace(/[^\w]/g, '_')}`;
  const go = (path: string) => {
    setFocus(path);
    requestAnimationFrame(() => document.getElementById(idOf(path))?.focus());
  };
  const toggle = (path: string, to?: boolean) => {
    const next = new Set(open);
    const want = to ?? !open.has(path);
    if (want) next.add(path);
    else next.delete(path);
    setOpen(next);
  };
  const onKey = (e: React.KeyboardEvent, path: string) => {
    const i = rows.findIndex((r) => r.path === path);
    const r = rows[i];
    const k = e.key;
    if (k === 'ArrowDown' && rows[i + 1]) go(rows[i + 1].path);
    else if (k === 'ArrowUp' && rows[i - 1]) go(rows[i - 1].path);
    else if (k === 'Home') go(rows[0].path);
    else if (k === 'End') go(rows[rows.length - 1].path);
    else if (k === 'ArrowRight' && r.branch) open.has(path) ? rows[i + 1] && go(rows[i + 1].path) : toggle(path, true);
    else if (k === 'ArrowLeft') r.branch && open.has(path) ? toggle(path, false) : r.parent && go(r.parent);
    else if ((k === 'Enter' || k === ' ') && r.branch) toggle(path);
    else if (k === 'c' && (e.metaKey || e.ctrlKey) === false) onCopyPath?.(path);
    else if (k === '*' && r.parent) {
      const sib = rows.filter((x) => x.parent === r.parent && x.branch).map((x) => x.path);
      setOpen(new Set([...open, ...sib]));
    } else return;
    e.preventDefault();
  };

  const node = (v: Json, path: string, key: string | number | null, level: number, last: boolean, pos: number, size: number): React.ReactNode => {
    const branch = isBranch(v);
    const isOpen = branch && open.has(path);
    const list = branch ? entries(v) : [];
    const brackets = Array.isArray(v) ? ['[', ']'] : ['{', '}'];
    const hit = hits?.has(path);
    const comma = last ? null : <span className="text-subtle-foreground">,</span>;
    return (
      <li
        key={path}
        id={idOf(path)}
        role="treeitem"
        aria-level={level}
        aria-setsize={size}
        aria-posinset={pos}
        aria-expanded={branch ? isOpen : undefined}
        tabIndex={focus === path ? 0 : -1}
        onFocus={(e) => e.target === e.currentTarget && setFocus(path)}
        onKeyDown={(e) => {
          if (e.target !== e.currentTarget) return;
          e.stopPropagation();
          onKey(e, path);
        }}
        className="group/row outline-none"
      >
        <div
          onClick={() => {
            setFocus(path);
            if (branch) toggle(path);
          }}
          className={cn(
            'relative flex min-h-7 cursor-default items-start rounded-md py-0.5 pe-2 text-[13.5px] leading-6 transition-colors duration-150 hover:bg-accent group-focus-visible/row:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-sm:min-h-11 max-sm:py-2.5',
            hit && 'bg-[color-mix(in_oklab,var(--warning)_10%,transparent)]',
          )}
          style={{ paddingInlineStart: 6 + (level - 1) * 18 }}
        >
          <span aria-hidden className="mt-1 grid size-4 shrink-0 place-items-center text-subtle-foreground">
            {branch && <Icon name="chevron-right" size={13} strokeWidth={2} className={cn('transition-[rotate] duration-200', isOpen && 'rotate-90')} />}
          </span>
          <span className="ms-1 min-w-0 flex-1">
            {key !== null && (
              <>
                <button
                  type="button"
                  tabIndex={-1}
                  title={t.copyPath}
                  onClick={(e) => {
                    e.stopPropagation();
                    onCopyPath?.(path);
                  }}
                  className={cn('rounded-[3px] font-medium hover:underline hover:decoration-dotted hover:underline-offset-4', typeof key === 'number' ? 'tabular-nums text-subtle-foreground' : 'text-foreground')}
                >
                  {typeof key === 'number' ? key : <Mark text={key} q={q} />}
                </button>
                <span className="text-subtle-foreground">: </span>
              </>
            )}
            {branch ? (
              <>
                <span className="text-subtle-foreground">{brackets[0]}</span>
                {!isOpen && (
                  <>
                    <span className="mx-1 rounded bg-muted px-1.5 text-[11.5px] tabular-nums text-muted-foreground">
                      {list.length} {Array.isArray(v) ? t.items : t.keys}
                    </span>
                    <span className="text-subtle-foreground">{brackets[1]}</span>
                    {comma}
                  </>
                )}
              </>
            ) : (
              <>
                {typeof v === 'string' && q ? <span className="break-all text-[color-mix(in_oklab,var(--primary)_42%,var(--muted-foreground))]">&quot;<Mark text={v} q={q} />&quot;</span> : <ValueText v={v} />}
                {comma}
              </>
            )}
          </span>
        </div>
        {branch && isOpen && (
          <>
            <ul role="group" className="relative">
              {/* The guide line of the level */}
              <span aria-hidden className="pointer-events-none absolute bottom-1 top-0 w-px bg-border" style={{ insetInlineStart: 6 + (level - 1) * 18 + 7.5 }} />
              {list.map(([k, c], j) => node(c, childPath(path, k), k, level + 1, j === list.length - 1, j + 1, list.length))}
            </ul>
            <div className="text-[13.5px] leading-6 text-subtle-foreground" style={{ paddingInlineStart: 6 + (level - 1) * 18 + 20 }}>
              {brackets[1]}
              {comma}
            </div>
          </>
        )}
      </li>
    );
  };

  return (
    <ul role="tree" aria-label={label ?? t.json} className="kitfx-json">
      {node(data, '$', null, 1, true, 1, 1)}
    </ul>
  );
}

export type JsonViewerVariant = 'tree' | 'table' | 'search' | 'response';

export interface JsonViewerProps {
  variant?: JsonViewerVariant;
  /** The data, as a value or as JSON text */
  data: Json | string;
  /** Accessible name and the title above */
  label?: string;
  /** Levels open at the start. Default 2. */
  depth?: number;
  /** search: a query at the start, for demos */
  defaultQuery?: string;
  /** response: the request line, e.g. "GET /v1/orders/4821" */
  request?: string;
  /** response: HTTP status code */
  status?: number;
  /** response: status text, e.g. "OK" */
  statusText?: string;
  /** response: duration in ms */
  duration?: number;
  /** response: response headers */
  headers?: Record<string, string>;
  labels?: Partial<Texts>;
  className?: string;
}

/**
 * JSON as a tree, a table, a searchable tree or an API response.
 *   <JsonViewer label="order.json" data={order} depth={2} />
 *   <JsonViewer variant="table" data={rows} />
 *   <JsonViewer variant="search" data={config} defaultQuery="timeout" />
 *   <JsonViewer variant="response" request="GET /v1/orders/4821" status={200} duration={182} data={order} headers={…} />
 */
export function JsonViewer({ variant = 'tree', data: raw, label, depth = 2, defaultQuery = '', request, status = 200, statusText, duration, headers, labels, className }: JsonViewerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const data = useMemo<Json>(() => {
    if (typeof raw !== 'string') return raw;
    try {
      return JSON.parse(raw) as Json;
    } catch {
      return raw;
    }
  }, [raw]);
  const startOpen = useMemo(() => {
    const s = new Set<string>();
    const walk = (v: Json, path: string, level: number) => {
      if (!isBranch(v) || level > depth) return;
      s.add(path);
      for (const [k, c] of entries(v)) walk(c, childPath(path, k), level + 1);
    };
    walk(data, '$', 1);
    return s;
  }, [data, depth]);
  const [open, setOpen] = useState<Set<string>>(startOpen);
  const [query, setQuery] = useState(defaultQuery);
  const [view, setView] = useState<'table' | 'tree'>('table');
  const [tab, setTab] = useState<'body' | 'headers'>('body');
  const [copyState, copy] = useCopy();
  const [pathState, copyPath] = useCopy();
  const [lastPath, setLastPath] = useState('');
  const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
  const q = query.trim().toLowerCase();
  const found = useMemo(() => (q ? searchPaths(data, q) : null), [data, q]);
  const shownOpen = found ? new Set([...open, ...found.open]) : open;
  const bytes = new TextEncoder().encode(JSON.stringify(data)).length;
  const fmtSize = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 1 })} kB`);
  const onCopyPath = (p: string) => {
    setLastPath(p);
    copyPath(p);
  };

  const toolbar = (extra?: React.ReactNode, tree = true) => (
    <div className="flex min-h-12 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5">
      {extra}
      <span className="ms-auto flex items-center gap-0.5">
        {tree && <ToolButton icon="maximize" label={t.expandAll} onClick={() => setOpen(new Set(branchPaths(data)))} />}
        {tree && <ToolButton icon="minimize" label={t.collapseAll} onClick={() => setOpen(new Set(['$']))} />}
        <ToolButton icon={copyState === 'copied' ? 'check' : 'copy'} label={copyState === 'copied' ? t.copied : t.copy} onClick={() => copy(text)} />
      </span>
    </div>
  );
  const pathLine = (
    <p aria-live="polite" className="flex min-h-9 items-center gap-2 border-t border-border px-4 text-[12px] text-subtle-foreground">
      {pathState === 'copied' ? (
        <>
          <Icon name="check" size={13} className="text-success" />
          <span>{t.pathCopied}</span>
          <span className="truncate font-medium text-foreground">{lastPath}</span>
        </>
      ) : (
        <span>{t.keyHint}</span>
      )}
    </p>
  );
  const shell = (children: React.ReactNode) => <div className={cn('w-full overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>{children}</div>;
  const treeBox = (h = 'max-h-[26rem]') => (
    <div className={cn('kit-scrollbar relative overflow-auto p-2', h)}>
      <JsonTree data={data} t={t} open={shownOpen} setOpen={setOpen} q={q} hits={found?.hits} label={label} onCopyPath={onCopyPath} />
    </div>
  );

  /* ---------- table: an array of objects ---------- */
  if (variant === 'table') {
    const rows = Array.isArray(data) ? (data.filter((r) => isBranch(r) && !Array.isArray(r)) as { [k: string]: Json }[]) : [];
    const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
    return shell(
      <>
        {toolbar(
          <>
            {label && <span className="me-2 text-[13px] font-semibold text-foreground">{label}</span>}
            <Segmented label={t.view} value={view} onChange={setView} options={[['table', t.table], ['tree', t.raw]]} />
            <span className="text-[12px] tabular-nums text-subtle-foreground">
              {rows.length} {t.items}
            </span>
          </>,
          view === 'tree',
        )}
        {view === 'table' ? (
          <>
          {/* Phones get a card per row, larger screens the table */}
          <ul className="grid gap-px bg-border sm:hidden">
            {rows.map((r, i) => (
              <li key={i} className="bg-card px-4 py-3">
                <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-[13px]">
                  {cols.map((c) => (
                    <div key={c} className="contents">
                      <dt className="text-muted-foreground">{c}</dt>
                      <dd className="min-w-0 truncate text-end">{r[c] === undefined ? <span className="text-subtle-foreground">·</span> : isBranch(r[c]) ? <span className="rounded bg-muted px-1.5 text-[11.5px] text-muted-foreground">{Array.isArray(r[c]) ? `[${(r[c] as Json[]).length}]` : '{…}'}</span> : <ValueText v={r[c]} />}</dd>
                    </div>
                  ))}
                </dl>
              </li>
            ))}
          </ul>
          <div tabIndex={0} role="region" aria-label={label ?? t.json} className="kit-scrollbar overflow-x-auto outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)] max-sm:hidden">
            <table className="w-full min-w-[34rem] text-left text-[13px]">
              <caption className="sr-only">{label ?? t.json}</caption>
              <thead>
                <tr className="border-b border-border">
                  {cols.map((c) => (
                    <th key={c} scope="col" className="whitespace-nowrap px-4 py-2.5 text-[12px] font-medium text-muted-foreground">
                      {c}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((r, i) => (
                  <tr key={i} className="border-b border-border transition-colors last:border-b-0 hover:bg-accent/60">
                    {cols.map((c) => (
                      <td key={c} className="whitespace-nowrap px-4 py-2.5 align-top">
                        {r[c] === undefined ? <span className="text-subtle-foreground">·</span> : isBranch(r[c]) ? <span className="rounded bg-muted px-1.5 text-[11.5px] text-muted-foreground">{Array.isArray(r[c]) ? `[${(r[c] as Json[]).length}]` : '{…}'}</span> : <ValueText v={r[c]} />}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          </>
        ) : (
          treeBox()
        )}
      </>,
    );
  }

  /* ---------- search ---------- */
  if (variant === 'search') {
    const count = found?.hits.size ?? 0;
    return shell(
      <>
        <div className="flex items-center gap-2 border-b border-border px-3">
          <Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
          <input
            type="search"
            aria-label={t.search}
            placeholder={t.search}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            className="h-12 w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-sm [&::-webkit-search-cancel-button]:hidden"
          />
          {q && (
            <span key={count} aria-live="polite" className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[12px] tabular-nums text-muted-foreground transition-[scale] duration-200 starting:scale-110">
              {count} {t.matches}
            </span>
          )}
        </div>
        {q && count === 0 ? <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t.noMatch}</p> : treeBox()}
        {pathLine}
      </>,
    );
  }

  /* ---------- response: an API answer ---------- */
  if (variant === 'response') {
    const ok = status < 300;
    const warn = status >= 400 && status < 500;
    return shell(
      <>
        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-4 py-3">
          {request && (
            <span className="flex min-w-0 items-center gap-2 text-[13px]">
              <span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 py-0.5 text-[11px] font-semibold tracking-[0.04em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{request.split(' ')[0]}</span>
              <span className="truncate font-medium text-foreground">{request.split(' ').slice(1).join(' ')}</span>
            </span>
          )}
          <span className="ms-auto flex items-center gap-3 text-[12px] tabular-nums text-muted-foreground">
            <span className="flex items-center gap-1.5">
              <span aria-hidden className={cn('relative size-2 rounded-full', ok ? 'bg-success' : warn ? 'bg-warning' : 'bg-destructive')}>
                {ok && <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" style={{ animationIterationCount: 2 }} />}
              </span>
              <span className="sr-only">{t.status}</span>
              <span className="font-semibold text-foreground">{status}</span> {statusText}
            </span>
            {duration !== undefined && (
              <span>
                <span className="sr-only">{t.time} </span>
                {duration} ms
              </span>
            )}
            <span>
              <span className="sr-only">{t.size} </span>
              {fmtSize(bytes)}
            </span>
          </span>
        </div>
        {toolbar(<Segmented label={t.view} value={tab} onChange={setTab} options={[['body', t.body], ['headers', `${t.headers}${headers ? ` ${Object.keys(headers).length}` : ''}`]]} />)}
        {tab === 'body' ? (
          treeBox('max-h-[22rem]')
        ) : (
          <dl className="grid grid-cols-[minmax(0,1fr)] p-2 text-[13px] sm:grid-cols-[auto_minmax(0,1fr)]">
            {Object.entries(headers ?? {}).map(([k, v]) => (
              <div key={k} className="contents">
                <dt className="px-3 pt-2 font-medium text-muted-foreground sm:py-1.5">{k}</dt>
                <dd className="break-all px-3 pb-2 text-foreground sm:py-1.5">{v}</dd>
              </div>
            ))}
          </dl>
        )}
      </>,
    );
  }

  /* ---------- tree ---------- */
  return shell(
    <>
      {toolbar(label && <span className="flex items-center gap-2 text-[13px] font-semibold text-foreground"><Icon name="file-code" size={15} className="text-muted-foreground" />{label}</span>)}
      {treeBox()}
      {pathLine}
    </>,
  );
}

function ToolButton({ icon, label, onClick }: { icon: 'maximize' | 'minimize' | 'copy' | 'check'; label: string; onClick: () => void }) {
  return (
    <button type="button" aria-label={label} title={label} onClick={onClick} className="grid size-11 place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] focus-visible:text-foreground active:scale-90 sm:size-8">
      <Icon name={icon} size={15} className={cn(icon === 'check' && 'text-success')} />
    </button>
  );
}

function Segmented<T extends string>({ label, value, onChange, options }: { label: string; value: T; onChange: (v: T) => void; options: [T, string][] }) {
  const ref = useRef<HTMLDivElement>(null);
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className="inline-flex h-12 rounded-lg bg-muted p-0.5 sm:h-8">
      {options.map(([v, l]) => (
        <button
          key={v}
          type="button"
          role="radio"
          aria-checked={value === v}
          onClick={() => onChange(v)}
          onKeyDown={(e) => {
            if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
            const i = options.findIndex((o) => o[0] === v);
            const j = (i + (e.key === 'ArrowRight' ? 1 : -1) + options.length) % options.length;
            onChange(options[j][0]);
            (ref.current?.querySelectorAll('button')[j] as HTMLButtonElement | undefined)?.focus();
          }}
          tabIndex={value === v ? 0 : -1}
          className={cn('rounded-[7px] px-3 text-[12.5px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', value === v ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
        >
          {l}
        </button>
      ))}
    </div>
  );
}

Häufige Fragen

Wie installiere ich JSON-Ansicht?

Mit aiw add @ai-website/json-viewer. Der Befehl legt die Datei components/ui/json-viewer.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und copy-button. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat JSON-Ansicht?

4 Varianten, Baum, Tabelle, Suche und API-Antwort. Du wählst sie mit der Prop variant, zum Beispiel variant="search".

Kann ich die Texte von JSON-Ansicht übersetzen?

Ja. JSON-Ansicht bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „JSON-Daten“, „Einträge“ und „Schlüssel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent JSON-Ansicht einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini JSON-Ansicht mit aiw_components und installieren sie mit aiw_add @ai-website/json-viewer. Nenn im Prompt die Variante, etwa variant="table", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich JSON-Ansicht ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden JsonViewer-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert