{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "knob",
  "title": "Knob",
  "type": "registry:ui",
  "description": "Circular dial input. Drag to rotate, keyboard accessible, SVG-rendered. Useful for compact numeric controls (volume, brightness, gauges).",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/knob/knob.ts",
      "content": "import { LitElement, css, html } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nconst startAngle = -Math.PI * 0.75\nconst endAngle = Math.PI * 0.75\nconst sweep = endAngle - startAngle\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v))\n}\n\nfunction 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\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-knob> — the registry Knob as a web component.\n *\n * Form-associated: submits `name=value` with its <form>, supports reset.\n */\nexport class UipKnob extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: inline-block; vertical-align: middle; }`]\n\n  static properties = {\n    value: { type: Number, reflect: true },\n    defaultValue: { type: Number, attribute: 'default-value' },\n    name: { reflect: true },\n    min: { type: Number },\n    max: { type: Number },\n    step: { type: Number },\n    size: { type: Number },\n    strokeWidth: { type: Number, attribute: 'stroke-width' },\n    valueColor: { attribute: 'value-color' },\n    rangeColor: { attribute: 'range-color' },\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'readonly', reflect: true },\n    showValue: { converter: trueUnlessFalse, attribute: 'show-value' },\n    valueTemplate: { attribute: 'value-template' },\n    accessibleLabel: { attribute: 'aria-label' },\n    isDragging: { state: true },\n  }\n\n  value = 0\n  defaultValue?: number\n  name?: string\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  valueTemplate?: string\n  accessibleLabel = 'Value'\n\n  private isDragging = false\n  private initialValue = 0\n  private internals = this.attachInternals()\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', 'knob')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = this.defaultValue\n    }\n    this.initialValue = this.value\n    this.addEventListener('wheel', this.onWheel, { passive: false })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.removeEventListener('wheel', this.onWheel)\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value')) {\n      this.internals.setFormValue(String(this.value ?? 0))\n    }\n  }\n\n  formResetCallback() {\n    this.setValue(this.initialValue)\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private snap(v: number) {\n    const stepped = Math.round((v - this.min) / this.step) * this.step + this.min\n    return clamp(stepped, this.min, this.max)\n  }\n\n  private setValue(v: number) {\n    if (this.disabled || this.readOnly) return\n    const next = this.snap(v)\n    if (next === this.value) return\n    this.value = next\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n  }\n\n  private get svg(): SVGSVGElement | null {\n    return this.renderRoot?.querySelector('svg')\n  }\n\n  private angleFromEvent(e: PointerEvent): number {\n    const rect = this.svg!.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  private 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 ? this.max : this.min\n    }\n    return this.min + (a / sweep) * (this.max - this.min)\n  }\n\n  private onPointerDown(e: PointerEvent) {\n    if (this.disabled || this.readOnly || e.button !== 0) return\n    const target = e.currentTarget as Element\n    target.setPointerCapture(e.pointerId)\n    this.isDragging = true\n    this.setValue(this.angleToValue(this.angleFromEvent(e)))\n  }\n\n  private onPointerMove(e: PointerEvent) {\n    if (!this.isDragging) return\n    this.setValue(this.angleToValue(this.angleFromEvent(e)))\n  }\n\n  private onPointerUp(e: PointerEvent) {\n    if (!this.isDragging) return\n    this.isDragging = false\n    const target = e.currentTarget as Element\n    if (target.hasPointerCapture(e.pointerId)) {\n      target.releasePointerCapture(e.pointerId)\n    }\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.disabled || this.readOnly) return\n    const big = this.step * 10\n    switch (e.key) {\n      case 'ArrowUp':\n      case 'ArrowRight':\n        e.preventDefault()\n        this.setValue(this.value + this.step)\n        break\n      case 'ArrowDown':\n      case 'ArrowLeft':\n        e.preventDefault()\n        this.setValue(this.value - this.step)\n        break\n      case 'PageUp':\n        e.preventDefault()\n        this.setValue(this.value + big)\n        break\n      case 'PageDown':\n        e.preventDefault()\n        this.setValue(this.value - big)\n        break\n      case 'Home':\n        e.preventDefault()\n        this.setValue(this.min)\n        break\n      case 'End':\n        e.preventDefault()\n        this.setValue(this.max)\n        break\n    }\n  }\n\n  private onWheel = (e: WheelEvent) => {\n    if (this.disabled || this.readOnly) return\n    if (this.matches(':focus-within') || this.matches(':hover')) {\n      e.preventDefault()\n      this.setValue(this.value + (e.deltaY < 0 ? this.step : -this.step))\n    }\n  }\n\n  render() {\n    const val = clamp(this.value, this.min, this.max)\n    const percent = this.max === this.min ? 0 : (val - this.min) / (this.max - this.min)\n\n    const rangePath = arcPath(50, 50, 40, startAngle, endAngle)\n    const valuePath = arcPath(50, 50, 40, startAngle, startAngle + sweep * percent)\n\n    const displayVal = this.valueTemplate ? this.valueTemplate.replace('{value}', String(val)) : String(val)\n\n    return html`\n      <svg\n        part=\"base\"\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          this.disabled && 'cursor-not-allowed opacity-50',\n          !this.disabled && !this.readOnly && 'cursor-pointer',\n        )}\n        width=${this.size}\n        height=${this.size}\n        viewBox=\"0 0 100 100\"\n        role=\"slider\"\n        aria-label=${this.accessibleLabel}\n        aria-valuemin=${this.min}\n        aria-valuemax=${this.max}\n        aria-valuenow=${val}\n        aria-disabled=${this.disabled ? 'true' : 'false'}\n        aria-readonly=${this.readOnly ? 'true' : 'false'}\n        tabindex=${this.disabled ? -1 : 0}\n        @pointerdown=${this.onPointerDown}\n        @pointermove=${this.onPointerMove}\n        @pointerup=${this.onPointerUp}\n        @pointercancel=${this.onPointerUp}\n        @keydown=${this.onKeyDown}\n      >\n        <path d=${rangePath} stroke=${this.rangeColor} stroke-width=${this.strokeWidth} fill=\"none\" stroke-linecap=\"round\" />\n        <path d=${valuePath} stroke=${this.valueColor} stroke-width=${this.strokeWidth} fill=\"none\" stroke-linecap=\"round\" />\n        ${this.showValue\n          ? html`\n              <text x=\"50\" y=\"55\" text-anchor=\"middle\" font-size=\"18\" class=\"fill-foreground font-medium\">\n                ${displayVal}\n              </text>\n            `\n          : null}\n      </svg>\n    `\n  }\n}\n\ncustomElements.get('uip-knob') || customElements.define('uip-knob', UipKnob)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-knob': UipKnob\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/knob/knob.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
