{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "scroll-spy",
  "title": "ScrollSpy",
  "type": "registry:ui",
  "description": "In-page navigation list with scroll-spy. Renders a vertical list of links; the active item highlights as the user scrolls through anchored sections.",
  "categories": [
    "navigation"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/scroll-spy/scroll-spy.ts",
      "content": "import { LitElement, css, html, isServer, nothing, type TemplateResult } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type ScrollSpyTurn = 'straight' | 'sharp' | 'rounded'\nexport type ScrollSpyVariant =\n  | 'default'\n  | 'line'\n  | 'angle'\n  | 'sharp'\n  | 'rounded'\n  | 'stepper'\n  | 'scrollspy'\n  | 'tabs'\n  | 'pills'\nexport type ScrollSpyIndicatorMode = 'segment' | 'fill' | 'progress' | 'pill' | 'dot' | 'line'\nexport type ScrollSpyPosition = 'right' | 'left' | 'top' | 'bottom'\nexport type ScrollSpyRailPosition = 'left' | 'right'\n\nexport interface ScrollSpyItem {\n  href: string\n  title: string\n  depth?: number\n  children?: ScrollSpyItem[]\n}\n\ninterface Marker {\n  value: string\n  depth: number\n  x: number\n  y: number\n  top: number\n  bottom: number\n}\n\nfunction flattenItems(rawItems: ScrollSpyItem[]): { value: string; title: string; depth: number; parentValue?: string | null }[] {\n  const result: { value: string; title: string; depth: number; parentValue?: string | null }[] = []\n  function walk(list: ScrollSpyItem[], depth = 1, parentVal: string | null = null) {\n    for (const it of list) {\n      result.push({\n        value: it.href,\n        depth: it.depth ?? depth,\n        title: it.title,\n        parentValue: parentVal,\n      })\n      if (it.children && it.children.length > 0) {\n        walk(it.children, depth + 1, it.href)\n      }\n    }\n  }\n  walk(rawItems)\n  return result\n}\n\nfunction resolveColor(color = 'primary') {\n  if (color === 'primary') return { bg: 'bg-primary', stroke: 'stroke-primary', border: 'border-primary' }\n  if (color === 'foreground') return { bg: 'bg-foreground', stroke: 'stroke-foreground', border: 'border-foreground' }\n  if (color === 'destructive') return { bg: 'bg-destructive', stroke: 'stroke-destructive', border: 'border-destructive' }\n  if (color === 'secondary') return { bg: 'bg-secondary', stroke: 'stroke-secondary', border: 'border-secondary' }\n  return { bg: `bg-${color}`, stroke: `stroke-${color}`, border: `border-${color}` }\n}\n\nfunction depthX(relDepth: number, isRightRail: boolean, listWidth: number): number {\n  if (!isRightRail) {\n    if (relDepth <= 1) return 1\n    if (relDepth === 2) return 13\n    if (relDepth === 3) return 21\n    return 21 + (relDepth - 3) * 8\n  }\n  const base = listWidth - 1\n  if (relDepth <= 1) return base\n  if (relDepth === 2) return base - 12\n  if (relDepth === 3) return base - 20\n  return base - 20 - (relDepth - 3) * 8\n}\n\nfunction buildCircuitPath(markers: Marker[], endIndex: number, rounded: boolean, edge: 'top' | 'bottom'): string {\n  if (markers.length === 0 || endIndex < 0) return ''\n  const end = Math.min(endIndex, markers.length - 1)\n  const first = markers[0]!\n  const parts: string[] = [`M ${first.x} ${first.top}`]\n\n  for (let i = 0; i <= end; i++) {\n    const curr = markers[i]!\n    if (i === end) {\n      const targetY = edge === 'top' ? curr.top : curr.bottom\n      parts.push(`L ${curr.x} ${targetY}`)\n      break\n    }\n    const next = markers[i + 1]\n    if (!next) {\n      parts.push(`L ${curr.x} ${curr.bottom}`)\n      break\n    }\n    parts.push(`L ${curr.x} ${curr.bottom}`)\n    if (curr.x === next.x) {\n      parts.push(`L ${curr.x} ${next.top}`)\n      continue\n    }\n    const gap = Math.max(0, next.top - curr.bottom)\n    const absDx = Math.abs(next.x - curr.x)\n    const transitionH = Math.min(gap, absDx)\n    if (transitionH <= 1) {\n      parts.push(`L ${next.x} ${next.top}`)\n      continue\n    }\n    const y1 = curr.bottom + (gap - transitionH) / 2\n    let y2 = y1 + transitionH\n    if (next.top - y2 <= 0.5) y2 = next.top\n    if (y1 - curr.bottom > 0.5) parts.push(`L ${curr.x} ${y1}`)\n    if (rounded) {\n      const midY = (y1 + y2) / 2\n      parts.push(`C ${curr.x} ${midY}, ${next.x} ${midY}, ${next.x} ${y2}`)\n    } else {\n      parts.push(`L ${next.x} ${y2}`)\n    }\n    if (next.top - y2 > 0.5) parts.push(`L ${next.x} ${next.top}`)\n  }\n  return parts.join(' ')\n}\n\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\n/**\n * <uip-scroll-spy> — Scroll-spy component for docs rails, steppers, and TOC.\n */\nexport class UipScrollSpy extends LitElement {\n  static styles = [tailwind, css`:host { display: block; position: relative; }`]\n\n  static properties = {\n    title: { type: String },\n    items: { type: Array },\n    value: { reflect: true },\n    defaultValue: { attribute: 'default-value' },\n    offsetTop: { type: Number, attribute: 'offset-top' },\n    bounds: { type: Number },\n    scrollContainer: { attribute: 'scroll-container' },\n    affix: { type: Boolean },\n    variant: { type: String },\n    turn: { type: String },\n    indicator: { type: String },\n    keepScrolled: { type: Boolean, attribute: 'keep-scrolled' },\n    highlightParent: { attribute: 'highlight-parent', converter: trueByDefault },\n    lineWidth: { attribute: 'line-width' },\n    color: { type: String },\n    position: { type: String },\n    railPosition: { attribute: 'rail-position' },\n    activeValue: { state: true },\n    scrollProgress: { state: true },\n    trackPath: { state: true },\n    pathLength: { state: true },\n    activeStart: { state: true },\n    activeEnd: { state: true },\n    straightHighlight: { state: true },\n  }\n\n  title = ''\n  items: ScrollSpyItem[] = []\n  value = ''\n  defaultValue = ''\n  offsetTop = 0\n  bounds = 5\n  scrollContainer?: string | HTMLElement | null = null\n  affix = false\n  variant: ScrollSpyVariant = 'line'\n  turn?: ScrollSpyTurn\n  indicator: ScrollSpyIndicatorMode = 'line'\n  keepScrolled = false\n  highlightParent = true\n  lineWidth = 'default'\n  color = 'primary'\n  position: ScrollSpyPosition = 'right'\n  railPosition?: ScrollSpyRailPosition\n\n  private activeValue = ''\n  private scrollProgress = 0\n  private trackPath = ''\n  private pathLength = 0\n  private activeStart = 0\n  private activeEnd = 0\n  private straightHighlight = { top: 0, height: 0, visible: false }\n\n  private containerEl: HTMLElement | Window | null = null\n  private scrollCleanup?: () => void\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'scroll-spy')\n    this.setAttribute('role', 'navigation')\n    this.setAttribute('aria-label', 'Scroll spy navigation')\n\n    if (!this.value && this.defaultValue) {\n      this.value = this.defaultValue\n    } else if (!this.value && this.items.length > 0) {\n      this.value = this.items[0]?.href ?? ''\n    }\n    this.activeValue = this.value\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.scrollCleanup?.()\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n\n    if (changed.has('scrollContainer') || !this.containerEl) {\n      this.setupContainer()\n    }\n\n    if (changed.has('items') || changed.has('value') || changed.has('turn') || changed.has('indicator')) {\n      this.recompute()\n    }\n  }\n\n  private setupContainer() {\n    this.scrollCleanup?.()\n    let c: HTMLElement | Window | null = window\n    if (typeof this.scrollContainer === 'string') {\n      c = document.querySelector(this.scrollContainer) as HTMLElement | null\n    } else if (this.scrollContainer instanceof HTMLElement) {\n      c = this.scrollContainer\n    }\n    this.containerEl = c ?? window\n\n    let raf = 0\n    const onScroll = () => {\n      cancelAnimationFrame(raf)\n      raf = requestAnimationFrame(() => this.recomputeActive())\n    }\n\n    this.containerEl.addEventListener('scroll', onScroll, { passive: true })\n    window.addEventListener('resize', onScroll)\n    this.recomputeActive()\n\n    this.scrollCleanup = () => {\n      cancelAnimationFrame(raf)\n      this.containerEl?.removeEventListener('scroll', onScroll)\n      window.removeEventListener('resize', onScroll)\n    }\n  }\n\n  private recomputeActive() {\n    const c = this.containerEl\n    const flat = flattenItems(this.items)\n    if (!c || flat.length === 0) return\n\n    const isWin = c === window\n    let currentScroll = 0\n    let maxScroll = 1\n\n    if (isWin) {\n      currentScroll = window.scrollY\n      maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)\n    } else {\n      const el = c as HTMLElement\n      currentScroll = el.scrollTop\n      maxScroll = Math.max(1, el.scrollHeight - el.clientHeight)\n    }\n\n    this.scrollProgress = Math.min(1, Math.max(0, currentScroll / maxScroll))\n    this.dispatchEvent(new CustomEvent('progress', { detail: { progress: this.scrollProgress } }))\n\n    const containerTop = isWin ? 0 : (c as HTMLElement).getBoundingClientRect().top\n    const threshold = containerTop + this.offsetTop + this.bounds + 40\n\n    let candidate = ''\n    for (const item of flat) {\n      const sel = item.value.startsWith('#') ? item.value : `#${item.value}`\n      const target = isWin ? document.querySelector(sel) : (c as HTMLElement).querySelector(sel)\n      if (!target) continue\n      const r = target.getBoundingClientRect()\n      if (r.top <= threshold) {\n        candidate = item.value\n      } else if (candidate) {\n        break\n      }\n    }\n\n    if (!candidate && flat.length > 0) {\n      candidate = flat[0]!.value\n    }\n\n    if (candidate && candidate !== this.activeValue) {\n      this.activeValue = candidate\n      this.value = candidate\n      this.dispatchEvent(new CustomEvent('value-change', { detail: { value: candidate }, bubbles: true, composed: true }))\n      this.recompute()\n    }\n  }\n\n  scrollToHref(href: string) {\n    this.activeValue = href\n    this.value = href\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: href }, bubbles: true, composed: true }))\n\n    const c = this.containerEl\n    const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto'\n\n    if (c && c !== window) {\n      const cEl = c as HTMLElement\n      const target = cEl.querySelector(href) as HTMLElement | null\n      if (target) {\n        const cRect = cEl.getBoundingClientRect()\n        const tRect = target.getBoundingClientRect()\n        const top = cEl.scrollTop + (tRect.top - cRect.top) - this.offsetTop\n        cEl.scrollTo({ top, behavior })\n      }\n    } else {\n      const target = document.querySelector(href) as HTMLElement | null\n      if (target) {\n        const top = window.scrollY + target.getBoundingClientRect().top - this.offsetTop\n        window.scrollTo({ top, behavior })\n      }\n    }\n\n    this.recompute()\n  }\n\n  goToPrev() {\n    const flat = flattenItems(this.items)\n    const idx = flat.findIndex((i) => i.value === this.activeValue)\n    if (idx > 0 && flat[idx - 1]) this.scrollToHref(flat[idx - 1]!.value)\n  }\n\n  goToNext() {\n    const flat = flattenItems(this.items)\n    const idx = flat.findIndex((i) => i.value === this.activeValue)\n    if (idx >= 0 && idx < flat.length - 1 && flat[idx + 1]) this.scrollToHref(flat[idx + 1]!.value)\n  }\n\n  private recompute() {\n    const list = this.renderRoot?.querySelector('[data-slot=\"scroll-spy-list\"]') as HTMLElement | null\n    if (!list) return\n    const listRect = list.getBoundingClientRect()\n    const flat = flattenItems(this.items)\n    const linkEls = Array.from(this.renderRoot?.querySelectorAll('[data-slot=\"scroll-spy-link\"]') ?? []) as HTMLElement[]\n\n    if (linkEls.length === 0) return\n    const isRightRail = this.position === 'left' && this.railPosition === 'right'\n    const listWidth = listRect.width || 180\n\n    const markers: Marker[] = []\n    linkEls.forEach((el) => {\n      const val = el.getAttribute('href') ?? ''\n      const item = flat.find((f) => f.value === val)\n      if (!item) return\n      const rect = el.getBoundingClientRect()\n      const top = rect.top - listRect.top\n      const bottom = rect.bottom - listRect.top\n      const relDepth = item.depth\n      markers.push({\n        value: val,\n        depth: relDepth,\n        x: depthX(relDepth, isRightRail, listWidth),\n        y: top + rect.height / 2,\n        top,\n        bottom,\n      })\n    })\n\n    markers.sort((a, b) => a.y - b.y)\n    if (markers.length === 0) return\n\n    const resolvedTurn = this.turn ?? (this.variant === 'angle' || this.variant === 'rounded' ? 'rounded' : 'straight')\n    const activeIdx = markers.findIndex((m) => m.value === this.activeValue)\n\n    if (resolvedTurn === 'straight') {\n      if (activeIdx >= 0) {\n        const active = markers[activeIdx]!\n        const isFill = this.indicator === 'fill' || this.keepScrolled\n        this.straightHighlight = {\n          top: isFill ? markers[0]!.top : active.top,\n          height: Math.max(isFill ? active.bottom - markers[0]!.top : active.bottom - active.top, 14),\n          visible: true,\n        }\n      } else {\n        this.straightHighlight = { top: 0, height: 0, visible: false }\n      }\n      return\n    }\n\n    // Circuit mode\n    const rounded = resolvedTurn === 'rounded'\n    const fullPath = buildCircuitPath(markers, markers.length - 1, rounded, 'bottom')\n    this.trackPath = fullPath\n    this.straightHighlight = { top: 0, height: 0, visible: false }\n\n    const svgPath = this.renderRoot?.querySelector('path[data-measure]') as SVGPathElement | null\n    let total = 100\n    if (svgPath) {\n      svgPath.setAttribute('d', fullPath)\n      try {\n        total = svgPath.getTotalLength() || 100\n      } catch {\n        total = 100\n      }\n    }\n    this.pathLength = total\n\n    if (activeIdx >= 0) {\n      const isFill = this.indicator === 'fill' || this.keepScrolled\n      let startLen = 0\n      if (!isFill && svgPath) {\n        svgPath.setAttribute('d', buildCircuitPath(markers, activeIdx, rounded, 'top'))\n        try {\n          startLen = svgPath.getTotalLength() || 0\n        } catch {}\n      }\n      let endLen = total\n      if (svgPath) {\n        svgPath.setAttribute('d', buildCircuitPath(markers, activeIdx, rounded, 'bottom'))\n        try {\n          endLen = svgPath.getTotalLength() || total\n        } catch {}\n      }\n      this.activeStart = startLen\n      this.activeEnd = endLen\n    }\n  }\n\n  private isItemActive(val: string) {\n    return this.activeValue === val\n  }\n\n  private isItemScrolled(val: string) {\n    if (!this.keepScrolled) return false\n    const flat = flattenItems(this.items)\n    const activeIdx = flat.findIndex((i) => i.value === this.activeValue)\n    const idx = flat.findIndex((i) => i.value === val)\n    return idx >= 0 && idx <= activeIdx\n  }\n\n  render() {\n    const isTopOrBottom = this.position === 'top' || this.position === 'bottom'\n    const colorStyles = resolveColor(this.color)\n\n    if (this.position === 'top') {\n      const isScrollSpyTabs = this.variant === 'scrollspy' || this.variant === 'tabs' || this.variant === 'pills'\n      const flat = flattenItems(this.items)\n      const activeIdx = Math.max(0, flat.findIndex((i) => i.value === this.activeValue))\n\n      if (isScrollSpyTabs) {\n        return html`\n          <div\n            data-slot=\"scroll-spy-top\"\n            class=\"border-border/70 bg-card/85 scrollbar-none relative sticky top-0 z-20 flex w-full items-center gap-1 overflow-x-auto rounded-xl border p-1.5 shadow-xs backdrop-blur-md\"\n          >\n            ${flat.map((item, idx) => {\n              const isCurrent = idx === activeIdx\n              return html`\n                <button\n                  type=\"button\"\n                  data-active=${isCurrent ? 'true' : 'false'}\n                  class=${cn(\n                    'group relative flex shrink-0 items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 outline-none select-none',\n                    'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]',\n                    isCurrent\n                      ? 'bg-primary/10 text-primary font-semibold shadow-2xs'\n                      : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',\n                  )}\n                  @click=${() => this.scrollToHref(item.value)}\n                >\n                  ${isCurrent\n                    ? html`<span\n                        class=\"bg-primary animate-in fade-in zoom-in-75 size-1.5 shrink-0 rounded-full duration-150\"\n                      ></span>`\n                    : nothing}\n                  <span class=\"truncate\">${item.title}</span>\n                </button>\n              `\n            })}\n            <div class=\"bg-border/20 absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-xl\">\n              <div\n                class=\"bg-primary h-full transition-[width] duration-150 ease-out\"\n                style=${styleMap({ width: `${Math.round(this.scrollProgress * 100)}%` })}\n              ></div>\n            </div>\n          </div>\n        `\n      }\n\n      return html`\n        <div\n          data-slot=\"scroll-spy-stepper-top\"\n          class=\"border-border/70 bg-card/85 scrollbar-none sticky top-0 z-20 flex w-full items-center gap-1.5 overflow-x-auto rounded-xl border p-2 shadow-xs backdrop-blur-md\"\n        >\n          ${flat.map((item, idx) => {\n            const isCurrent = idx === activeIdx\n            const isCompleted = idx < activeIdx\n            return html`\n              <button\n                type=\"button\"\n                data-active=${isCurrent ? 'true' : 'false'}\n                class=${cn(\n                  'group flex shrink-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium transition-[color,background-color,transform] duration-150 outline-none select-none',\n                  'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]',\n                  isCurrent\n                    ? 'bg-primary/10 text-foreground font-medium shadow-2xs'\n                    : isCompleted\n                      ? 'text-foreground/80 hover:bg-muted/50'\n                      : 'text-muted-foreground hover:bg-muted/30 hover:text-foreground',\n                )}\n                @click=${() => this.scrollToHref(item.value)}\n              >\n                <span\n                  class=${cn(\n                    'flex size-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] transition-all duration-200',\n                    isCurrent\n                      ? 'bg-primary text-primary-foreground scale-105 font-semibold shadow-2xs'\n                      : isCompleted\n                        ? 'bg-primary/15 text-primary font-medium'\n                        : 'bg-muted text-muted-foreground/70',\n                  )}\n                >\n                  ${isCompleted\n                    ? html`<svg class=\"size-3 stroke-[2.5]\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n                      </svg>`\n                    : idx + 1}\n                </span>\n                <span class=\"max-w-[120px] truncate\">${item.title}</span>\n              </button>\n              ${idx < flat.length - 1\n                ? html`<div\n                    class=${cn(\n                      'bg-border/70 h-0.5 max-w-10 min-w-4 flex-1 rounded-full transition-colors duration-200',\n                      isCompleted && 'bg-primary',\n                    )}\n                  ></div>`\n                : nothing}\n            `\n          })}\n        </div>\n      `\n    }\n\n    if (this.position === 'bottom') {\n      const flat = flattenItems(this.items)\n      const activeIdx = Math.max(0, flat.findIndex((i) => i.value === this.activeValue))\n      const activeItem = flat[activeIdx]\n\n      return html`\n        <div\n          data-slot=\"scroll-spy-stepper-bottom\"\n          class=\"border-border/80 bg-background/95 sticky bottom-3 z-30 mx-auto flex items-center gap-2 rounded-full border px-3 py-1.5 shadow-md backdrop-blur-md select-none\"\n        >\n          <button\n            type=\"button\"\n            aria-label=\"Previous section\"\n            ?disabled=${activeIdx <= 0}\n            class=\"text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-all active:scale-95 disabled:pointer-events-none disabled:opacity-25\"\n            @click=${() => this.goToPrev()}\n          >\n            <svg class=\"size-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15.75 19.5L8.25 12l7.5-7.5\" />\n            </svg>\n          </button>\n\n          <div class=\"flex items-center gap-1.5 px-1\">\n            ${flat.map(\n              (item, idx) => html`\n                <button\n                  type=\"button\"\n                  aria-label=${`Jump to section ${idx + 1}`}\n                  class=${cn(\n                    'h-1.5 cursor-pointer rounded-full transition-all duration-200',\n                    idx === activeIdx\n                      ? 'bg-primary w-5'\n                      : idx < activeIdx\n                        ? 'bg-primary/40 hover:bg-primary/60 w-2'\n                        : 'bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2',\n                  )}\n                  @click=${() => this.scrollToHref(item.value)}\n                ></button>\n              `,\n            )}\n          </div>\n\n          <div class=\"border-border/60 flex items-center gap-2 border-l pl-2\">\n            <span class=\"text-foreground max-w-[130px] truncate text-xs font-medium tracking-tight\">\n              ${activeItem?.title || ''}\n            </span>\n            <span class=\"bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums\">\n              ${Math.round(this.scrollProgress * 100)}%\n            </span>\n          </div>\n\n          <button\n            type=\"button\"\n            aria-label=\"Next section\"\n            ?disabled=${activeIdx >= flat.length - 1}\n            class=\"text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-all active:scale-95 disabled:pointer-events-none disabled:opacity-25\"\n            @click=${() => this.goToNext()}\n          >\n            <svg class=\"size-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M8.25 4.5l7.5 7.5-7.5 7.5\" />\n            </svg>\n          </button>\n        </div>\n      `\n    }\n\n    // Side position (left or right)\n    const isRightRail = this.position === 'left' && this.railPosition === 'right'\n    const resolvedTurn = this.turn ?? (this.variant === 'angle' || this.variant === 'rounded' ? 'rounded' : 'straight')\n    const isCircuit = resolvedTurn !== 'straight'\n\n    return html`\n      <nav\n        part=\"base\"\n        data-uipkge=\"\"\n        data-slot=\"scroll-spy\"\n        data-position=${this.position}\n        data-variant=${this.variant}\n        class=\"relative flex flex-col text-sm w-full\"\n      >\n        ${this.title\n          ? html`<p\n              part=\"title\"\n              data-slot=\"scroll-spy-title\"\n              class=${cn(\n                'text-foreground mb-3 text-sm font-semibold tracking-tight',\n                isRightRail && 'pr-3 text-right',\n              )}\n            >\n              ${this.title}\n            </p>`\n          : nothing}\n\n        <ul\n          part=\"list\"\n          data-slot=\"scroll-spy-list\"\n          class=${cn(\n            'relative flex flex-col space-y-1 text-sm',\n            !isCircuit && (isRightRail ? 'border-border border-r' : 'border-border border-l'),\n          )}\n        >\n          <!-- Indicator layer -->\n          <div\n            data-slot=\"scroll-spy-indicator\"\n            aria-hidden=\"true\"\n            class=\"pointer-events-none absolute inset-0 overflow-visible\"\n          >\n            ${isCircuit\n              ? html`\n                  <svg class=\"absolute inset-0 size-full overflow-visible\" fill=\"none\">\n                    <path data-measure class=\"invisible\" fill=\"none\" />\n                    <path\n                      d=${this.trackPath}\n                      class=\"stroke-border\"\n                      stroke-width=\"2.5\"\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                    />\n                    ${this.trackPath && this.activeEnd > 0\n                      ? html`\n                          <path\n                            d=${this.trackPath}\n                            class=${colorStyles.stroke}\n                            stroke-width=\"2.5\"\n                            stroke-linecap=\"butt\"\n                            stroke-linejoin=\"round\"\n                            style=${styleMap({\n                              strokeDasharray: `${Math.max(this.activeEnd - this.activeStart, 0)} ${Math.max(this.pathLength, 1)}`,\n                              strokeDashoffset: `${-this.activeStart}`,\n                              transition:\n                                'stroke-dashoffset 260ms cubic-bezier(0.16, 1, 0.3, 1), stroke-dasharray 260ms cubic-bezier(0.16, 1, 0.3, 1)',\n                            })}\n                          />\n                        `\n                      : nothing}\n                  </svg>\n                `\n              : this.straightHighlight.visible\n                ? html`\n                    <div\n                      class=${cn('absolute z-10 rounded-none', colorStyles.bg)}\n                      style=${styleMap({\n                        top: `${this.straightHighlight.top}px`,\n                        height: `${this.straightHighlight.height}px`,\n                        width: '2.5px',\n                        left: isRightRail ? undefined : '-2.5px',\n                        right: isRightRail ? '-2.5px' : undefined,\n                        transition:\n                          'top 260ms cubic-bezier(0.16, 1, 0.3, 1), height 260ms cubic-bezier(0.16, 1, 0.3, 1), opacity 200ms ease-out',\n                      })}\n                    ></div>\n                  `\n                : nothing}\n          </div>\n\n          <!-- Items list -->\n          ${this.items && this.items.length > 0\n            ? this.items.map((item) => this.renderItemNode(item, 1, isCircuit, isRightRail))\n            : html`<slot></slot>`}\n        </ul>\n      </nav>\n    `\n  }\n\n  private renderItemNode(item: ScrollSpyItem, depth = 1, isCircuit = false, isRightRail = false): TemplateResult {\n    const isActive = this.isItemActive(item.href)\n    const isScrolled = this.isItemScrolled(item.href)\n\n    return html`\n      <li data-slot=\"scroll-spy-item\" class=\"relative flex flex-col\">\n        <a\n          href=${item.href}\n          data-slot=\"scroll-spy-link\"\n          aria-current=${isActive ? 'location' : nothing}\n          data-active=${isActive ? 'true' : 'false'}\n          data-scrolled=${isScrolled ? 'true' : 'false'}\n          class=${cn(\n            'group block rounded-none leading-snug no-underline transition-[color,border-color,background-color,opacity] duration-200 ease-out outline-none',\n            isCircuit\n              ? [\n                  'py-1',\n                  isRightRail\n                    ? [\n                        'text-right',\n                        depth <= 1 && 'pr-4 pl-2 text-sm',\n                        depth === 2 && 'pr-7 pl-2 text-xs',\n                        depth >= 3 && 'pr-10 pl-2 text-xs',\n                      ]\n                    : [\n                        depth <= 1 && 'pr-2 pl-4 text-sm',\n                        depth === 2 && 'pr-2 pl-7 text-xs',\n                        depth >= 3 && 'pr-2 pl-10 text-xs',\n                      ],\n                  isActive ? 'text-foreground font-medium' : isScrolled ? 'text-foreground/85' : 'text-muted-foreground hover:text-foreground',\n                ]\n              : isRightRail\n                ? [\n                    '-mr-px border-r border-transparent py-0.5 pr-3 pl-2 text-right',\n                    depth <= 1 && 'text-sm',\n                    depth >= 2 && 'pr-6 text-xs',\n                    isActive ? 'text-foreground font-medium' : isScrolled ? 'text-foreground/85' : 'text-muted-foreground hover:text-foreground',\n                  ]\n                : [\n                    '-ml-px border-l border-transparent py-0.5 pr-2 pl-3',\n                    depth <= 1 && 'text-sm',\n                    depth >= 2 && 'pl-6 text-xs',\n                    isActive ? 'text-foreground font-medium' : isScrolled ? 'text-foreground/85' : 'text-muted-foreground hover:text-foreground',\n                  ],\n          )}\n          @click=${(e: MouseEvent) => {\n            e.preventDefault()\n            this.scrollToHref(item.href)\n          }}\n        >\n          ${item.title}\n        </a>\n        ${item.children && item.children.length > 0\n          ? item.children.map((c) => this.renderItemNode(c, depth + 1, isCircuit, isRightRail))\n          : nothing}\n      </li>\n    `\n  }\n}\n\ncustomElements.get('uip-scroll-spy') || customElements.define('uip-scroll-spy', UipScrollSpy)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-scroll-spy': UipScrollSpy\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/scroll-spy/scroll-spy.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
