{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "knob",
  "title": "Knob",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/knob/Knob.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { SVGAttributes } from 'svelte/elements'\n\n  export interface KnobProps extends Omit<SVGAttributes<SVGSVGElement>, 'onchange'> {\n    /** Two-way bound value (`bind:value`). Clamped to [min, max] and snapped to `step`. */\n    value?: number\n    min?: number\n    max?: number\n    step?: number\n    size?: number\n    strokeWidth?: number\n    valueColor?: string\n    rangeColor?: string\n    disabled?: boolean\n    readonly?: boolean\n    showValue?: boolean\n    /** Accessible name for the slider. Prefer over a visible label when none is nearby. */\n    ariaLabel?: string\n    /** Custom centered value content (must be SVG-safe, e.g. `<tspan>` or text). */\n    valueSnippet?: Snippet<[{ value: number }]>\n    /** Fires with the new value whenever it changes. */\n    onchange?: (value: number) => void\n    ref?: SVGSVGElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value = $bindable(0),\n    min = 0,\n    max = 100,\n    step = 1,\n    size = 100,\n    strokeWidth = 14,\n    valueColor = 'var(--primary)',\n    rangeColor = 'var(--muted)',\n    disabled = false,\n    readonly = false,\n    showValue = true,\n    ariaLabel,\n    valueSnippet,\n    onchange,\n    ref = $bindable(null),\n    ...restProps\n  }: KnobProps = $props()\n\n  let isDragging = $state(false)\n\n  const clamped = $derived(clamp(value, min, max))\n  const percent = $derived(max === min ? 0 : (clamped - min) / (max - min))\n\n  const startAngle = -Math.PI * 0.75\n  const endAngle = Math.PI * 0.75\n  const sweep = endAngle - startAngle\n\n  const rangePath = $derived(arcPath(50, 50, 40, startAngle, endAngle))\n  const valuePath = $derived(arcPath(50, 50, 40, startAngle, startAngle + sweep * percent))\n\n  function arcPath(cx: number, cy: number, r: number, a1: number, a2: number) {\n    const x1 = cx + r * Math.cos(a1)\n    const y1 = cy + r * Math.sin(a1)\n    const x2 = cx + r * Math.cos(a2)\n    const y2 = cy + r * Math.sin(a2)\n    const large = a2 - a1 > Math.PI ? 1 : 0\n    return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`\n  }\n\n  function clamp(v: number, lo: number, hi: number) {\n    return Math.min(hi, Math.max(lo, v))\n  }\n\n  function snap(v: number) {\n    const stepped = Math.round((v - min) / step) * step + min\n    return clamp(stepped, min, max)\n  }\n\n  function setValue(v: number) {\n    if (disabled || readonly) return\n    const next = snap(v)\n    if (next === value) return\n    value = next\n    onchange?.(next)\n  }\n\n  function angleFromEvent(e: PointerEvent): number {\n    const rect = ref!.getBoundingClientRect()\n    const cx = rect.left + rect.width / 2\n    const cy = rect.top + rect.height / 2\n    return Math.atan2(e.clientY - cy, e.clientX - cx)\n  }\n\n  function angleToValue(angle: number): number {\n    let a = angle - startAngle\n    if (a < 0) a += Math.PI * 2\n    if (a > sweep) {\n      return a < (Math.PI * 2 + sweep) / 2 ? max : min\n    }\n    return min + (a / sweep) * (max - min)\n  }\n\n  function onPointerDown(e: PointerEvent & { currentTarget: SVGSVGElement }) {\n    if (disabled || readonly) return\n    e.currentTarget.setPointerCapture(e.pointerId)\n    isDragging = true\n    setValue(angleToValue(angleFromEvent(e)))\n  }\n\n  function onPointerMove(e: PointerEvent) {\n    if (!isDragging) return\n    setValue(angleToValue(angleFromEvent(e)))\n  }\n\n  function onPointerUp(e: PointerEvent & { currentTarget: SVGSVGElement }) {\n    if (!isDragging) return\n    isDragging = false\n    try {\n      e.currentTarget.releasePointerCapture(e.pointerId)\n    } catch {\n      // pointer already released\n    }\n  }\n\n  function onKeyDown(e: KeyboardEvent) {\n    if (disabled || readonly) return\n    const big = step * 10\n    switch (e.key) {\n      case 'ArrowUp':\n      case 'ArrowRight':\n        e.preventDefault()\n        setValue(clamped + step)\n        break\n      case 'ArrowDown':\n      case 'ArrowLeft':\n        e.preventDefault()\n        setValue(clamped - step)\n        break\n      case 'PageUp':\n        e.preventDefault()\n        setValue(clamped + big)\n        break\n      case 'PageDown':\n        e.preventDefault()\n        setValue(clamped - big)\n        break\n      case 'Home':\n        e.preventDefault()\n        setValue(min)\n        break\n      case 'End':\n        e.preventDefault()\n        setValue(max)\n        break\n    }\n  }\n\n  // Non-passive wheel listener so preventDefault actually blocks page scroll.\n  $effect(() => {\n    const node = ref\n    if (!node) return\n    const wheelHandler = (e: WheelEvent) => {\n      if (disabled || readonly) return\n      e.preventDefault()\n      setValue(clamped + (e.deltaY < 0 ? step : -step))\n    }\n    node.addEventListener('wheel', wheelHandler, { passive: false })\n    return () => {\n      node.removeEventListener('wheel', wheelHandler)\n    }\n  })\n</script>\n\n<svg\n  bind:this={ref}\n  data-uipkge=\"\"\n  data-slot=\"knob\"\n  class={cn(\n    'focus-visible:ring-ring inline-block touch-none rounded-full outline-none select-none focus-visible:ring-2',\n    disabled && 'cursor-not-allowed opacity-50',\n    !disabled && !readonly && 'cursor-pointer',\n    className,\n  )}\n  width={size}\n  height={size}\n  viewBox=\"0 0 100 100\"\n  role=\"slider\"\n  aria-label={ariaLabel ?? 'Value'}\n  aria-valuemin={min}\n  aria-valuemax={max}\n  aria-valuenow={clamped}\n  aria-disabled={disabled || undefined}\n  aria-readonly={readonly || undefined}\n  tabindex={disabled ? -1 : 0}\n  onpointerdown={onPointerDown}\n  onpointermove={onPointerMove}\n  onpointerup={onPointerUp}\n  onpointercancel={onPointerUp}\n  onkeydown={onKeyDown}\n  {...restProps}\n>\n  <path d={rangePath} stroke={rangeColor} stroke-width={strokeWidth} fill=\"none\" stroke-linecap=\"round\" />\n  <path d={valuePath} stroke={valueColor} stroke-width={strokeWidth} fill=\"none\" stroke-linecap=\"round\" />\n  {#if showValue}\n    <text x=\"50\" y=\"55\" text-anchor=\"middle\" font-size=\"18\" class=\"fill-foreground font-medium\">\n      {#if valueSnippet}\n        {@render valueSnippet({ value: clamped })}\n      {:else}\n        {clamped}\n      {/if}\n    </text>\n  {/if}\n</svg>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/knob/Knob.svelte"
    },
    {
      "path": "packages/registry-svelte/components/knob/index.ts",
      "content": "export { default as Knob, type KnobProps } from './Knob.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/knob/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Circular dial input. Drag to rotate, keyboard accessible, SVG-rendered. Useful for compact numeric controls (volume, brightness, gauges).",
  "categories": [
    "form"
  ]
}