Menu

Change language

GitHub repo

Xml Tree View

xml-tree-viewui

Collapsible XML tree viewer with color-coded tags and attributes, click-to-copy, live search/filter, and expand/collapse-all controls. Parses XML strings and renders elements, text, comments, and CDATA with contained scrolling.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/xml-tree-view.json
Manifest:https://uipkge.dev/r/svelte/xml-tree-view.jsonInstalls to:src/lib/components/ui/xml-tree-view/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
data

Raw XML string to parse and display.

string—required
expandDepthnumber1optional
maxDepthnumber100optional
showSearchbooleantrueoptional
showToolbarbooleantrueoptional
rootLabel

Override path root label; defaults to the document element name.

string—optional
oncopy(value: string, path: string) => void—optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

XmlAttr
interface XmlAttr {
  name: string
  value: string
}
XmlNode
interface XmlNode {
  type: XmlNodeType
  /** Tag name for elements; empty for text / comment / cdata. */
  name: string
  attributes: XmlAttr[]
  /** Character data for text / comment / cdata nodes. */
  text: string
  children: XmlNode[]
}
ParseXmlResult
interface ParseXmlResult {
  root: XmlNode | null
  error: string | null
}

npm dependencies

Files installed (4)

  • src/lib/components/ui/xml-tree-view/XmlTreeView.svelte9.1 kB
    <script lang="ts" module>
      import type { HTMLAttributes } from 'svelte/elements'
    
      // `oncopy` here is the copy-notification callback (value, path) — it shadows the
      // native clipboard-event prop, which consumers don't need on this root.
      export interface XmlTreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'oncopy'> {
        /** Raw XML string to parse and display. */
        data: string
        expandDepth?: number
        maxDepth?: number
        showSearch?: boolean
        showToolbar?: boolean
        /** Override path root label; defaults to the document element name. */
        rootLabel?: string
        oncopy?: (value: string, path: string) => void
      }
    </script>
    
    <script lang="ts">
      import { AlertCircle, CodeXml, FoldVertical, Search, UnfoldVertical } from '@lucide/svelte'
      import { cn } from '$lib/utils'
      import XmlTreeNode from './XmlTreeNode.svelte'
      import { countElements, isExpandable, parseXml, type XmlNode } from './types'
    
      let {
        data,
        expandDepth = 1,
        maxDepth = 100,
        showSearch = true,
        showToolbar = true,
        rootLabel,
        oncopy,
        class: className,
        ...restProps
      }: XmlTreeViewProps = $props()
    
      let expanded = $state<Set<string>>(new Set())
      let search = $state('')
      let copiedPath = $state<string | null>(null)
    
      const parsed = $derived(parseXml(data))
      const root = $derived(parsed.root)
      const parseError = $derived(parsed.error)
    
      function pathKey(path: string[]): string {
        return path.length ? '/' + path.join('/') : '/'
      }
    
      function walkExpandable(
        node: XmlNode,
        path: string[],
        depth: number,
        max: number,
        visit: (path: string[], node: XmlNode) => void,
      ) {
        if (depth >= max) return
        if (isExpandable(node)) {
          visit(path, node)
          const counts = new Map<string, number>()
          const totals = new Map<string, number>()
          for (const c of node.children) {
            if (c.type === 'element') totals.set(c.name, (totals.get(c.name) ?? 0) + 1)
          }
          node.children.forEach((child, i) => {
            let segment: string
            if (child.type === 'element') {
              const n = (counts.get(child.name) ?? 0) + 1
              counts.set(child.name, n)
              const total = totals.get(child.name) ?? 1
              segment = total > 1 ? `${child.name}[${n}]` : child.name
            } else if (child.type === 'comment') {
              segment = `comment()[${i}]`
            } else {
              segment = `text()[${i}]`
            }
            walkExpandable(child, [...path, segment], depth + 1, max, visit)
          })
        }
      }
    
      function defaultExpanded(): Set<string> {
        const next = new Set<string>()
        const r = root
        if (!r) return next
        // Root is always at path [] with key "/"
        walkExpandable(r, [], 0, expandDepth, (path) => {
          next.add(pathKey(path))
        })
        return next
      }
    
      // Reset expansion when the document or depth changes (mirrors Vue's immediate watcher).
      $effect(() => {
        const _d = data
        const _e = expandDepth
        expanded = defaultExpanded()
      })
    
      function toggle(path: string[]) {
        const key = pathKey(path)
        const next = new Set(expanded)
        if (next.has(key)) next.delete(key)
        else next.add(key)
        expanded = next
      }
    
      function isExpanded(path: string[]): boolean {
        return expanded.has(pathKey(path))
      }
    
      function expandAll() {
        const next = new Set<string>()
        const r = root
        if (!r) {
          expanded = next
          return
        }
        walkExpandable(r, [], 0, maxDepth, (path) => {
          next.add(pathKey(path))
        })
        expanded = next
      }
    
      function collapseAll() {
        expanded = new Set()
      }
    
      function matchesSearch(node: XmlNode): boolean {
        if (!search) return true
        const term = search.toLowerCase()
        const walk = (n: XmlNode): boolean => {
          if (n.type === 'element') {
            if (n.name.toLowerCase().includes(term)) return true
            if (n.attributes.some((a) => a.name.toLowerCase().includes(term) || a.value.toLowerCase().includes(term)))
              return true
            return n.children.some(walk)
          }
          return n.text.toLowerCase().includes(term)
        }
        return walk(node)
      }
    
      // Auto-expand nodes that contain search matches. Guarded to first-run so the
      // mount pass keeps the default expansion (mirrors Vue's non-immediate watcher).
      let searchPrimed = false
      $effect(() => {
        const q = search
        if (!searchPrimed) {
          searchPrimed = true
          return
        }
        if (!q) {
          expanded = defaultExpanded()
          return
        }
        const next = new Set<string>()
        const r = root
        if (!r) {
          expanded = next
          return
        }
        walkExpandable(r, [], 0, maxDepth, (path, node) => {
          if (matchesSearch(node)) next.add(pathKey(path))
        })
        expanded = next
      })
    
      const tagColor = 'text-violet-600 dark:text-violet-400'
      const attrNameColor = 'text-blue-600 dark:text-blue-400'
      const attrValueColor = 'text-emerald-600 dark:text-emerald-400'
      const textColor = 'text-emerald-600 dark:text-emerald-400'
      const commentColor = 'text-muted-foreground'
      const punctColor = 'text-muted-foreground'
    
      async function copyValue(value: string, path: string[]) {
        const p = pathKey(path)
        try {
          await navigator.clipboard.writeText(value)
          copiedPath = p
          oncopy?.(value, p)
          setTimeout(() => {
            if (copiedPath === p) copiedPath = null
          }, 1200)
        } catch {
          // clipboard unavailable
        }
      }
    
      const effectiveRootLabel = $derived(rootLabel ?? root?.name ?? 'xml')
    
      const summary = $derived.by(() => {
        if (parseError) return 'Parse error'
        if (!root) return 'Empty'
        const n = countElements(root)
        return `${root.name} · ${n} element${n === 1 ? '' : 's'}`
      })
    
      const searchMatchCount = $derived.by(() => {
        if (!search || !root) return 0
        let count = 0
        const term = search.toLowerCase()
        const walk = (n: XmlNode) => {
          if (n.type === 'element') {
            if (n.name.toLowerCase().includes(term)) count++
            for (const a of n.attributes) {
              if (a.name.toLowerCase().includes(term) || a.value.toLowerCase().includes(term)) count++
            }
            n.children.forEach(walk)
            return
          }
          if (n.text.toLowerCase().includes(term)) count++
        }
        walk(root)
        return count
      })
    </script>
    
    <div
      data-uipkge
      data-slot="xml-tree-view"
      class={cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', className)}
      {...restProps}
    >
      <!-- Toolbar -->
      {#if showToolbar || showSearch}
        <div class="border-border flex items-center gap-2 border-b px-3 py-2">
          <div class="flex items-center gap-1.5">
            <CodeXml class="text-muted-foreground size-4" />
            <span class="text-muted-foreground text-xs">{summary}</span>
          </div>
          <div class="ml-auto flex items-center gap-1">
            {#if showSearch && !parseError}
              <div class="relative">
                <Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
                <input
                  bind:value={search}
                  type="text"
                  placeholder="Filter..."
                  aria-label="Filter XML tree"
                  class="border-input bg-muted/40 focus:border-ring focus:ring-ring/30 h-7 w-32 rounded-md pr-2 pl-7 text-xs transition-[width] outline-none focus:w-44 focus:ring-2"
                />
              </div>
            {/if}
            {#if search && !parseError}
              <span class="text-muted-foreground text-xs">
                {searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'}
              </span>
            {/if}
            {#if !parseError}
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors"
                title="Expand all"
                aria-label="Expand all"
                onclick={expandAll}
              >
                <UnfoldVertical class="size-4" />
              </button>
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors"
                title="Collapse all"
                aria-label="Collapse all"
                onclick={collapseAll}
              >
                <FoldVertical class="size-4" />
              </button>
            {/if}
          </div>
        </div>
      {/if}
    
      <!-- Parse error -->
      {#if parseError}
        <div class="text-destructive flex items-start gap-2 p-4 text-sm">
          <AlertCircle class="mt-0.5 size-4 shrink-0" />
          <div class="min-w-0">
            <p class="font-sans font-medium">Invalid XML</p>
            <p class="text-muted-foreground mt-1 font-mono text-xs break-words">{parseError}</p>
          </div>
        </div>
      {:else if root}
        <!-- Tree -->
        <div class="min-h-0 flex-1 overflow-auto p-2" role="tree" aria-label={effectiveRootLabel}>
          <XmlTreeNode
            node={root}
            path={[]}
            isRoot={true}
            {search}
            {maxDepth}
            {matchesSearch}
            {isExpanded}
            {toggle}
            {tagColor}
            {attrNameColor}
            {attrValueColor}
            {textColor}
            {commentColor}
            {punctColor}
            {copiedPath}
            oncopy={copyValue}
          />
        </div>
      {/if}
    </div>
    
  • src/lib/components/ui/xml-tree-view/XmlTreeNode.svelte13.4 kB
  • src/lib/components/ui/xml-tree-view/types.ts4.4 kB
  • src/lib/components/ui/xml-tree-view/index.ts0.3 kB

Raw manifest:https://uipkge.dev/r/svelte/xml-tree-view.json