{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pan-control",
  "title": "Pan Control",
  "description": "A left/right pan slider with a center fill, center detent, keyboard controls, accessible pan text, and double-click reset.",
  "dependencies": [
    "solid-js@^1.9.15",
    "@kobalte/core@https://github.com/jhomra21/kobalte/releases/download/kobalte-solid1-audiocn-e0e3bf095f05c7e61230a251b79181c6d834d408/kobalte-core-0.13.14-audiocn.0.e0e3bf09.tgz",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@audiocn-solid/core",
    "@audiocn-solid/use-audio-config"
  ],
  "files": [
    {
      "path": "components/ui/pan-control.tsx",
      "content": "import * as SliderPrimitive from \"@kobalte/core/slider\";\nimport { cva } from \"class-variance-authority\";\nimport { createMemo, createSignal } from \"solid-js\";\n\nimport { useAudioConfig } from \"@/hooks/use-audio-config\";\nimport type { AudioSize } from \"@/hooks/use-audio-config\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { roundValue } from \"@/lib/number\";\nimport { createCompatEffect } from \"@/lib/solid/effect\";\nimport { useKobalteSliderCompat } from \"@/lib/solid/kobalte-slider\";\nimport { omitProps } from \"@/lib/solid/props\";\nimport { cn } from \"@/lib/utils\";\n\nconst PERCENT = 100;\n\nconst DETENT_RANGE = 0.08;\n\nconst CENTER_TEXT = /^c(?:enter|entre)?$/iu;\n\nconst SIDE_PREFIX = /^[LR]/iu;\n\n/** \"L30\", \"C\", \"R30\". */\nexport const formatPan = (value: number): string => {\n  const amount = Math.round(Math.abs(value) * PERCENT);\n\n  if (amount === 0) {\n    return \"C\";\n  }\n\n  return `${value < 0 ? \"L\" : \"R\"}${amount}`;\n};\n\n/**\n * Reads \"L30\", \"R15\", \"C\" or a number from −100 to 100.\n * This is the inverse of formatPan.\n */\nexport const parsePan = (text: string): number | null => {\n  const trimmed = text.trim().replaceAll(\"\\u2212\", \"-\");\n\n  if (CENTER_TEXT.test(trimmed)) {\n    return 0;\n  }\n\n  const side = SIDE_PREFIX.test(trimmed) ? trimmed[0]?.toUpperCase() : null;\n\n  const digits = (side ? trimmed.slice(1) : trimmed).trim();\n\n  const amount = Number(digits) / PERCENT;\n\n  if (digits === \"\" || Number.isNaN(amount)) {\n    return null;\n  }\n\n  return side === \"L\" ? -Math.abs(amount) : amount;\n};\n\nexport const describePan = (value: number): string => {\n  const amount = Math.round(Math.abs(value) * PERCENT);\n\n  if (amount === 0) {\n    return \"Center\";\n  }\n\n  return `${amount}% ${value < 0 ? \"left\" : \"right\"}`;\n};\n\nconst panControlVariants = cva(\n  \"group/pan-control relative flex w-full touch-none items-center select-none data-disabled:opacity-50\",\n  {\n    defaultVariants: {\n      size: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"[--pan-thumb-size:0.875rem] [--pan-track-size:0.25rem]\",\n        lg: \"[--pan-thumb-size:1rem] [--pan-track-size:0.375rem]\",\n        sm: \"[--pan-thumb-size:0.75rem] [--pan-track-size:0.1875rem]\",\n      },\n    },\n  }\n);\n\ninterface SliderCompatProps {\n  changeFromPointer: (value: number) => number;\n  commitPointer: (value: number) => void;\n  currentValue: () => number;\n}\n\nconst SliderCompat = (props: SliderCompatProps) => {\n  const context = SliderPrimitive.useSliderContext();\n\n  let dragPosition = props.currentValue();\n\n  let pointerValue = props.currentValue();\n\n  useKobalteSliderCompat(context, {\n    onSlideEnd: (originalSlideEnd) => {\n      originalSlideEnd?.();\n      props.commitPointer(pointerValue);\n    },\n    onSlideMove: (originalSlideMove, { deltaX }) => {\n      if (context.state.isDisabled()) {\n        return;\n      }\n\n      const track = context.trackRef();\n\n      if (!track) {\n        originalSlideMove?.({ deltaX, deltaY: 0 });\n\n        return;\n      }\n\n      const { width } = track.getBoundingClientRect();\n\n      if (width <= 0) {\n        return;\n      }\n\n      const direction = context.isSlidingFromLeft() ? 1 : -1;\n      dragPosition = clamp(\n        dragPosition + direction * (deltaX / width) * 2,\n        -1,\n        1\n      );\n      pointerValue = props.changeFromPointer(dragPosition);\n    },\n    onSlideStart: (originalSlideStart, index, value) => {\n      originalSlideStart?.(index, value);\n\n      if (context.state.isDisabled()) {\n        return;\n      }\n\n      dragPosition = clamp(value, -1, 1);\n      pointerValue = props.changeFromPointer(dragPosition);\n    },\n    suppressStep: true,\n  });\n\n  return null;\n};\n\ntype SliderRootProps = Parameters<typeof SliderPrimitive.Root>[0];\n\nexport type PanControlProps = Omit<\n  SliderRootProps,\n  | \"children\"\n  | \"class\"\n  | \"className\"\n  | \"defaultValue\"\n  | \"disabled\"\n  | \"getValueLabel\"\n  | \"maxValue\"\n  | \"minValue\"\n  | \"onChange\"\n  | \"onChangeEnd\"\n  | \"orientation\"\n  | \"step\"\n  | \"value\"\n> & {\n  class?: string;\n  className?: string;\n  defaultValue?: number;\n  detent?: boolean;\n  disabled?: boolean;\n  format?: (value: number) => string;\n  largeStep?: number;\n  onValueChange?: (value: number) => void;\n  onValueCommitted?: (value: number) => void;\n  size?: AudioSize;\n  step?: number;\n  value?: number;\n};\n\nconst PAN_CONTROL_OWN = [\n  \"class\",\n  \"className\",\n  \"defaultValue\",\n  \"detent\",\n  \"disabled\",\n  \"format\",\n  \"largeStep\",\n  \"onValueChange\",\n  \"onValueCommitted\",\n  \"size\",\n  \"step\",\n  \"value\",\n] as const;\n\nexport const PanControl = (props: PanControlProps) => {\n  const rest = omitProps(props, PAN_CONTROL_OWN);\n\n  const config = useAudioConfig();\n\n  const size = () => props.size ?? config.size ?? \"default\";\n\n  const disabled = () => props.disabled ?? config.disabled ?? false;\n\n  const step = () => props.step ?? 0.05;\n\n  const largeStep = () => props.largeStep ?? 0.25;\n\n  const detent = () => props.detent ?? true;\n\n  const format = () => props.format ?? formatPan;\n\n  const [uncontrolled, setUncontrolled] = createSignal(\n    clamp(props.defaultValue ?? 0, -1, 1)\n  );\n\n  const value = () => props.value ?? uncontrolled();\n\n  let latestValue = value();\n\n  createCompatEffect(value, (next) => {\n    latestValue = next;\n  });\n\n  const quantize = (next: number, increment = step()): number => {\n    const stepped = -1 + Math.round((next + 1) / increment) * increment;\n\n    return roundValue(clamp(stepped, -1, 1));\n  };\n\n  const snapDrag = (next: number): number =>\n    detent() && Math.abs(next) < DETENT_RANGE ? 0 : next;\n\n  const change = (next: number) => {\n    if (next === latestValue) {\n      return;\n    }\n\n    latestValue = next;\n\n    if (props.value === undefined) {\n      setUncontrolled(next);\n    }\n\n    props.onValueChange?.(next);\n\n    if (props.value !== undefined) {\n      queueMicrotask(() => {\n        latestValue = value();\n      });\n    }\n  };\n\n  const commit = (next: number) => {\n    props.onValueCommitted?.(\n      detent() && Math.abs(next) < DETENT_RANGE ? 0 : next\n    );\n  };\n\n  const handleKeyDown = (event: KeyboardEvent) => {\n    const increment = event.shiftKey ? largeStep() : step();\n\n    let next: number | undefined;\n\n    switch (event.key) {\n      case \"ArrowUp\":\n      case \"ArrowRight\": {\n        next = latestValue + increment;\n\n        break;\n      }\n\n      case \"ArrowDown\":\n      case \"ArrowLeft\": {\n        next = latestValue - increment;\n\n        break;\n      }\n\n      case \"PageUp\": {\n        next = latestValue + largeStep();\n\n        break;\n      }\n\n      case \"PageDown\": {\n        next = latestValue - largeStep();\n\n        break;\n      }\n\n      case \"Home\": {\n        next = -1;\n\n        break;\n      }\n\n      case \"End\": {\n        next = 1;\n\n        break;\n      }\n\n      default: {\n        return;\n      }\n    }\n\n    event.preventDefault();\n\n    const quantized = quantize(next, Math.min(increment, step()));\n\n    change(quantized);\n    commit(quantized);\n  };\n\n  const start = () => Math.min(0, value());\n\n  const end = () => Math.max(0, value());\n\n  const sliderValue = createMemo(() => [value()]);\n\n  return (\n    <SliderPrimitive.Root\n      class={cn(\n        panControlVariants({\n          size: size(),\n        }),\n        props.class,\n        props.className\n      )}\n      data-centered={value() === 0 ? \"\" : undefined}\n      data-size={size()}\n      data-slot=\"pan-control\"\n      disabled={disabled()}\n      getValueLabel={() => format()(value())}\n      maxValue={1}\n      minValue={-1}\n      onChange={(next) => {\n        const current = next[0];\n\n        if (current === undefined) {\n          return;\n        }\n\n        change(snapDrag(current));\n      }}\n      orientation=\"horizontal\"\n      step={step()}\n      value={sliderValue()}\n      {...rest}\n    >\n      <SliderCompat\n        changeFromPointer={(next) => {\n          const changed = snapDrag(quantize(next));\n\n          change(changed);\n\n          return changed;\n        }}\n        commitPointer={commit}\n        currentValue={value}\n      />\n\n      <SliderPrimitive.Track class=\"relative flex h-(--pan-thumb-size) w-full items-center px-[calc(var(--pan-thumb-size)/2)] before:absolute before:inset-x-0 before:-inset-y-1.5 pointer-coarse:before:-inset-y-3\">\n        <div\n          class=\"bg-input/90 relative h-(--pan-track-size) w-full grow rounded-full\"\n          data-slot=\"pan-control-track\"\n        >\n          <span\n            aria-hidden=\"true\"\n            class=\"bg-border absolute top-1/2 left-1/2 h-[calc(var(--pan-track-size)*3)] w-px -translate-x-1/2 -translate-y-1/2\"\n            data-slot=\"pan-control-center\"\n          />\n\n          <div\n            aria-hidden=\"true\"\n            class=\"bg-primary absolute inset-y-0 left-(--pan-range-start) w-(--pan-range-size) rounded-full\"\n            data-slot=\"pan-control-range\"\n            style={{\n              \"--pan-range-size\": `${((end() - start()) / 2) * PERCENT}%`,\n              \"--pan-range-start\": `${((start() + 1) / 2) * PERCENT}%`,\n            }}\n          />\n        </div>\n\n        <SliderPrimitive.Thumb\n          aria-label=\"Pan\"\n          aria-valuetext={describePan(value())}\n          class=\"bg-background ring-foreground/15 hover:ring-ring/30 focus-visible:ring-ring/40 data-dragging:ring-ring/30 block size-(--pan-thumb-size) shrink-0 rounded-full shadow-sm ring-1 outline-hidden transition-[box-shadow] hover:ring-4 focus-visible:ring-4 data-dragging:ring-4\"\n          data-slot=\"pan-control-thumb\"\n          onDblClick={(event: MouseEvent) => {\n            event.preventDefault();\n            change(0);\n            commit(0);\n          }}\n          onKeyDown={handleKeyDown}\n        >\n          <SliderPrimitive.Input />\n        </SliderPrimitive.Thumb>\n      </SliderPrimitive.Track>\n\n      <span class=\"sr-only\">{format()(value())}</span>\n    </SliderPrimitive.Root>\n  );\n};\n\nexport { panControlVariants };\n",
      "type": "registry:ui"
    },
    {
      "path": "lib/solid/kobalte-slider.ts",
      "content": "import type { SliderContextValue } from \"@kobalte/core/slider\";\nimport * as Solid from \"solid-js\";\n\nimport { createCompatEffect } from \"@/lib/solid/effect\";\n\ninterface SliderCallbacks {\n  onChangeEnd?: () => void;\n  onSlideEnd?: (original: SliderContextValue[\"onSlideEnd\"]) => void;\n  onSlideMove?: (\n    original: SliderContextValue[\"onSlideMove\"],\n    delta: { deltaX: number; deltaY: number }\n  ) => void;\n  onSlideStart?: (\n    original: SliderContextValue[\"onSlideStart\"],\n    index: number,\n    value: number\n  ) => void;\n  suppressStep?: boolean;\n}\n\n/**\n * Kobalte 0.13.14 and 2.0.0-alpha.2 call these context methods dynamically.\n * SliderContextValue exposes them, but method replacement is not a documented\n * extension point, so keep the Solid 2 thumb-editability workaround and all\n * required pointer remapping in one adapter. Alpha.2's move handler calls\n * onChange twice: setThumbPercent emits the new value, then onChange(values())\n * replays the previous controlled value before Solid 2's queued update settles.\n * Its end handler likewise reads the old dragging array after writing it and\n * misses onChangeEnd. Adapt those two synchronous-read assumptions here, while\n * leaving vendor pointer capture, focus, keyboard and input handling intact.\n */\nexport const useKobalteSliderCompat = (\n  context: SliderContextValue,\n  callbacks: SliderCallbacks = {}\n): void => {\n  const originals = {\n    onSlideEnd: context.onSlideEnd,\n    onSlideMove: context.onSlideMove,\n    onSlideStart: context.onSlideStart,\n    onStepKeyDown: context.onStepKeyDown,\n  };\n\n  const queuedUpdates = \"onSettled\" in Solid;\n  let activeIndex: number | undefined;\n  let currentPosition: number | null = null;\n\n  const move: SliderContextValue[\"onSlideMove\"] = (delta) => {\n    if (!queuedUpdates) {\n      // 0.13.14 uses inversion alone for horizontal deltas, although thumb\n      // placement and track presses also use locale. Correct RTL before delegation.\n      const rtl = context.isSlidingFromLeft() === context.inverted();\n      originals.onSlideMove?.({\n        deltaX: rtl ? -delta.deltaX : delta.deltaX,\n        deltaY: delta.deltaY,\n      });\n\n      return;\n    }\n\n    if (activeIndex === undefined || context.state.isDisabled()) return;\n    const rect = context.trackRef()!.getBoundingClientRect();\n    const vertical = context.state.orientation() === \"vertical\";\n    const size = vertical ? rect.height : rect.width;\n\n    if (size <= 0) return;\n    currentPosition ??= context.state.getThumbPercent(activeIndex) * size;\n\n    const direction = vertical\n      ? context.isSlidingFromBottom()\n        ? -1\n        : 1\n      : context.isSlidingFromLeft()\n        ? 1\n        : -1;\n\n    currentPosition += (vertical ? delta.deltaY : delta.deltaX) * direction;\n    context.state.setThumbPercent(\n      activeIndex,\n      Math.min(1, Math.max(0, currentPosition / size))\n    );\n  };\n\n  context.onSlideStart = (index, value) => {\n    activeIndex = index;\n    currentPosition = null;\n\n    if (callbacks.onSlideStart)\n      callbacks.onSlideStart(originals.onSlideStart, index, value);\n    else originals.onSlideStart?.(index, value);\n  };\n\n  context.onSlideMove = (delta) => {\n    if (callbacks.onSlideMove) callbacks.onSlideMove(move, delta);\n    else move(delta);\n  };\n\n  context.onSlideEnd = () => {\n    if (callbacks.onSlideEnd) callbacks.onSlideEnd(originals.onSlideEnd);\n    else {\n      originals.onSlideEnd?.();\n\n      if (\n        queuedUpdates &&\n        activeIndex !== undefined &&\n        !context.state.isDisabled()\n      )\n        callbacks.onChangeEnd?.();\n    }\n\n    activeIndex = undefined;\n  };\n\n  if (callbacks.suppressStep) {\n    context.onStepKeyDown = () => undefined;\n  }\n\n  Solid.onCleanup(() => {\n    context.onSlideEnd = originals.onSlideEnd;\n    context.onSlideMove = originals.onSlideMove;\n    context.onSlideStart = originals.onSlideStart;\n    context.onStepKeyDown = originals.onStepKeyDown;\n  });\n\n  createCompatEffect(\n    () => [context.thumbs().length, context.state.isDisabled()] as const,\n    ([thumbCount, isDisabled]) => {\n      for (let index = 0; index < thumbCount; index += 1) {\n        context.state.setThumbEditable(index, !isDisabled);\n      }\n    }\n  );\n};\n",
      "type": "registry:lib"
    },
    {
      "path": "lib/audiocn-license.txt",
      "content": "Audio component source adapted from https://github.com/audiocn/ui.\nMIT License — Copyright (c) 2026 OrcDev\n\nRegular SVG icon geometry adapted from @phosphor-icons/react 2.1.10.\nMIT License — Copyright (c) 2020 Phosphor Icons\n\nThe following permission and warranty terms apply to both notices.\nRetain this file with copies or substantial portions of the source.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
      "type": "registry:file",
      "target": "@lib/audiocn-license.txt"
    }
  ],
  "type": "registry:ui"
}
