{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "knob",
  "title": "Knob",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/knob/knob.component.ts",
      "content": "import {\n  AfterViewInit,\n  ChangeDetectorRef,\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  SimpleChanges,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  numberAttribute,\n  signal,\n  type EmbeddedViewRef,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\n\n/** Context of the `renderValue` template (React `renderValue(value)`): `let-value`. */\nexport interface KnobValueContext {\n  $implicit: number\n}\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): number {\n  return Math.min(hi, Math.max(lo, v))\n}\n\nfunction arcPath(cx: number, cy: number, r: number, a1: number, a2: number): string {\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/** Renders the `renderValue` template with the current value (keeps one view, updates its context). */\n@Directive({ selector: '[uiKnobValue]', standalone: true })\nexport class UiKnobValueDirective implements OnChanges, OnDestroy {\n  @Input('uiKnobValue') template!: TemplateRef<KnobValueContext>\n  @Input('uiKnobValueOf') value = 0\n  private readonly vcr = inject(ViewContainerRef)\n  private view?: EmbeddedViewRef<KnobValueContext>\n\n  ngOnChanges(changes: SimpleChanges): void {\n    if (changes['template'] || !this.view) {\n      this.vcr.clear()\n      this.view = this.vcr.createEmbeddedView(this.template, { $implicit: this.value })\n    } else {\n      this.view.context.$implicit = this.value\n    }\n  }\n\n  ngOnDestroy(): void {\n    this.vcr.clear()\n  }\n}\n\n/**\n * Angular port of the React Knob: the dial IS the `<svg role=\"slider\">` (this host is\n * `contents`, so the svg lays out exactly like React's root). Drag anywhere on the dial\n * (pointer capture), Arrow keys step, PageUp / PageDown step x10, Home / End jump, and\n * the wheel steps without scrolling the page. `value` / `defaultValue` / `valueChange`\n * (`[(value)]`) plus `change` (React `onChange`) and NG_VALUE_ACCESSOR. `renderValue`\n * is a template for the centred text (`<ng-template let-value>`, SVG content).\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-knob, [ui-knob]',\n  standalone: true,\n  imports: [UiKnobValueDirective],\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiKnobComponent), multi: true }],\n  host: {\n    // class / aria-label belong to the svg (React's root); the host only stays out of layout.\n    '[attr.class]': '\"contents\"',\n    '[attr.aria-label]': 'null',\n  },\n  template: `\n    <svg\n      #svg\n      data-uipkge=\"\"\n      data-slot=\"knob\"\n      [attr.class]=\"svgClass\"\n      [attr.width]=\"size\"\n      [attr.height]=\"size\"\n      viewBox=\"0 0 100 100\"\n      role=\"slider\"\n      [attr.aria-label]=\"ariaLabel\"\n      [attr.aria-valuemin]=\"min\"\n      [attr.aria-valuemax]=\"max\"\n      [attr.aria-valuenow]=\"current\"\n      [attr.aria-disabled]=\"disabled || null\"\n      [attr.aria-readonly]=\"readonly || null\"\n      [attr.tabindex]=\"disabled ? -1 : 0\"\n      (pointerdown)=\"onPointerDown($event)\"\n      (pointermove)=\"onPointerMove($event)\"\n      (pointerup)=\"onPointerUp($event)\"\n      (pointercancel)=\"onPointerUp($event)\"\n      (keydown)=\"onKeyDown($event)\"\n      (blur)=\"onTouched()\"\n    >\n      <path\n        [attr.d]=\"rangePath\"\n        [attr.stroke]=\"rangeColor\"\n        [attr.stroke-width]=\"strokeWidth\"\n        fill=\"none\"\n        stroke-linecap=\"round\"\n      />\n      <path\n        [attr.d]=\"valuePath\"\n        [attr.stroke]=\"valueColor\"\n        [attr.stroke-width]=\"strokeWidth\"\n        fill=\"none\"\n        stroke-linecap=\"round\"\n      />\n      @if (showValue) {\n        <text x=\"50\" y=\"55\" text-anchor=\"middle\" font-size=\"18\" class=\"fill-foreground font-medium\">\n          @if (renderValue) {\n            <ng-container [uiKnobValue]=\"renderValue\" [uiKnobValueOf]=\"current\" />\n          } @else {\n            <ng-container>{{ current }}</ng-container>\n          }\n        </text>\n      }\n    </svg>\n  `,\n})\nexport class UiKnobComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {\n  // Zoneless-safe: form writes happen outside template events, so schedule a repaint.\n  private readonly cdr = inject(ChangeDetectorRef)\n  private readonly _value = signal<number | undefined>(undefined)\n  private readonly _internal = signal<number | null>(null)\n  private readonly dragging = signal(false)\n  private formBound = false\n  private onChangeFn: (v: number) => void = () => {}\n  private removeWheel?: () => void\n\n  /** Controlled value (React `value`). Reading it returns the current raw value. */\n  @Input()\n  set value(v: number | null | undefined) {\n    this._value.set(v ?? undefined)\n  }\n  get value(): number {\n    return this._value() ?? this._internal() ?? this.defaultValue\n  }\n  /** Initial value when uncontrolled. */\n  @Input({ transform: numberAttribute }) defaultValue = 0\n  @Output() valueChange = new EventEmitter<number>()\n  /** Also fired on every committed change (React `onChange`). */\n  @Output() change = new EventEmitter<number>()\n  @Input({ transform: numberAttribute }) min = 0\n  @Input({ transform: numberAttribute }) max = 100\n  @Input({ transform: numberAttribute }) step = 1\n  @Input({ transform: numberAttribute }) size = 100\n  @Input({ transform: numberAttribute }) strokeWidth = 14\n  @Input() valueColor = 'var(--primary)'\n  @Input() rangeColor = 'var(--muted)'\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) readonly = false\n  @Input({ transform: booleanAttribute }) showValue = true\n  @Input('class') className?: string\n  /** Accessible name for the slider. */\n  @Input('aria-label') ariaLabel = 'Value'\n  /** Custom renderer for the centred value text (SVG content, e.g. `<svg:tspan>`). */\n  @Input() renderValue?: TemplateRef<KnobValueContext>\n\n  @ViewChild('svg', { static: true }) svgRef!: ElementRef<SVGSVGElement>\n\n  onTouched: () => void = () => {}\n\n  /** Value clamped to [min, max] (what the dial and aria-valuenow show). */\n  get current(): number {\n    return clamp(this.value, this.min, this.max)\n  }\n\n  get svgClass(): string {\n    return 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      this.className,\n    )\n  }\n\n  get rangePath(): string {\n    return arcPath(50, 50, 40, startAngle, endAngle)\n  }\n\n  get valuePath(): string {\n    const percent = this.max === this.min ? 0 : (this.current - this.min) / (this.max - this.min)\n    return arcPath(50, 50, 40, startAngle, startAngle + sweep * percent)\n  }\n\n  private snap(v: number): 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  setValue(v: number): void {\n    if (this.disabled || this.readonly) return\n    const next = this.snap(v)\n    if (next === this.value) return\n    if (this._value() === undefined) this._internal.set(next)\n    else if (this.formBound) this._value.set(next)\n    this.valueChange.emit(next)\n    this.change.emit(next)\n    this.onChangeFn(next)\n  }\n\n  private angleFromEvent(e: PointerEvent): number {\n    const rect = this.svgRef.nativeElement.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) return a < (Math.PI * 2 + sweep) / 2 ? this.max : this.min\n    return this.min + (a / sweep) * (this.max - this.min)\n  }\n\n  onPointerDown(e: PointerEvent): void {\n    if (this.disabled || this.readonly) return\n    ;(e.target as Element).setPointerCapture?.(e.pointerId)\n    this.dragging.set(true)\n    this.setValue(this.angleToValue(this.angleFromEvent(e)))\n  }\n\n  onPointerMove(e: PointerEvent): void {\n    if (!this.dragging()) return\n    this.setValue(this.angleToValue(this.angleFromEvent(e)))\n  }\n\n  onPointerUp(e: PointerEvent): void {\n    if (!this.dragging()) return\n    this.dragging.set(false)\n    ;(e.target as Element).releasePointerCapture?.(e.pointerId)\n  }\n\n  onKeyDown(e: KeyboardEvent): void {\n    if (this.disabled || this.readonly) return\n    const big = this.step * 10\n    const next: Record<string, number> = {\n      ArrowUp: this.current + this.step,\n      ArrowRight: this.current + this.step,\n      ArrowDown: this.current - this.step,\n      ArrowLeft: this.current - this.step,\n      PageUp: this.current + big,\n      PageDown: this.current - big,\n      Home: this.min,\n      End: this.max,\n    }\n    if (!(e.key in next)) return\n    e.preventDefault()\n    this.setValue(next[e.key]!)\n  }\n\n  ngAfterViewInit(): void {\n    // Non-passive, so preventDefault stops the page from scrolling while the wheel turns the dial.\n    const node = this.svgRef.nativeElement\n    const handler = (e: WheelEvent) => {\n      if (this.disabled || this.readonly) return\n      e.preventDefault()\n      this.setValue(this.current + (e.deltaY < 0 ? this.step : -this.step))\n    }\n    node.addEventListener('wheel', handler, { passive: false })\n    this.removeWheel = () => node.removeEventListener('wheel', handler)\n  }\n\n  ngOnDestroy(): void {\n    this.removeWheel?.()\n  }\n\n  // ── ControlValueAccessor ──\n  writeValue(v: number | null): void {\n    this._value.set(v ?? undefined)\n    this.cdr.markForCheck()\n  }\n  registerOnChange(fn: (v: number) => void): void {\n    this.formBound = true\n    this.onChangeFn = fn\n  }\n  registerOnTouched(fn: () => void): void {\n    this.onTouched = fn\n  }\n  setDisabledState(isDisabled: boolean): void {\n    this.disabled = isDisabled\n    this.cdr.markForCheck()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/knob/knob.component.ts"
    },
    {
      "path": "packages/registry-angular/components/knob/index.ts",
      "content": "export { UiKnobComponent, type KnobValueContext } from './knob.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/knob/index.ts"
    }
  ],
  "dependencies": [
    "@angular/forms"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Circular dial input. Drag, wheel, arrow / page / home / end keys; SVG-rendered with a custom value template. Controlled or uncontrolled, usable as a form control.",
  "categories": [
    "form"
  ]
}