{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "range-slider",
  "title": "Range Slider",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/range-slider/range-slider.component.ts",
      "content": "import {\n  AfterViewInit,\n  ChangeDetectorRef,\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnDestroy,\n  Output,\n  ViewChild,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  numberAttribute,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\nimport { uniqueId } from '@/ui/popper/popper'\n\nexport type RangeSliderValue = [number, number]\nexport type RangeSliderColor = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | (string & {})\nexport type RangeSliderThumbSize = 'sm' | 'md' | 'lg'\nexport type RangeSliderTrackHeight = 'sm' | 'md' | 'lg'\n\nconst colorClasses: Record<string, string> = {\n  primary: 'bg-primary',\n  secondary: 'bg-secondary',\n  success: 'bg-success',\n  warning: 'bg-warning',\n  error: 'bg-destructive',\n  info: 'bg-info',\n}\n\nconst trackHeightClasses: Record<RangeSliderTrackHeight, string> = {\n  sm: 'h-1',\n  md: 'h-1.5',\n  lg: 'h-2',\n}\n\nconst thumbSizeClasses: Record<RangeSliderThumbSize, string> = {\n  sm: 'size-3',\n  md: 'size-4',\n  lg: 'size-5',\n}\n\nconst PAGE_KEYS = ['PageUp', 'PageDown']\nconst ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']\nconst BACK_KEYS: Record<'from-left' | 'from-right', string[]> = {\n  'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],\n  'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'],\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\nfunction linearScale(input: [number, number], output: [number, number]) {\n  return (value: number) => {\n    if (input[0] === input[1] || output[0] === output[1]) return output[0]\n    const ratio = (output[1] - output[0]) / (input[1] - input[0])\n    return output[0] + ratio * (value - input[0])\n  }\n}\nfunction decimalCount(value: number): number {\n  return (String(value).split('.')[1] || '').length\n}\nconst roundValue = (value: number, decimals: number) => Math.round(value * 10 ** decimals) / 10 ** decimals\nconst toPercent = (value: number, min: number, max: number) => clamp((100 / (max - min)) * (value - min), 0, 100)\nfunction closestIndex(values: number[], next: number): number {\n  const distances = values.map((v) => Math.abs(v - next))\n  return distances.indexOf(Math.min(...distances))\n}\n/** Radix: keeps the thumb inside the track at the ends (0% / 100%). */\nfunction thumbInBoundsOffset(size: number, percent: number, direction: number): number {\n  const half = size / 2\n  return (half - linearScale([0, 50], [0, half])(percent) * direction) * direction\n}\n\n/**\n * Angular port of the React RangeSlider (Radix Slider Root with two thumbs, wrapped in a\n * label / hint / min-max readout / error layout). Same DOM as React: the Radix root\n * span[data-slot=range-slider] > track > range, optional ticks, two span[role=slider]\n * thumbs (optional value bubbles). Radix behaviour: pressing the track moves the closest\n * thumb and drags it (pointer capture), Arrow / Shift+Arrow / PageUp / PageDown / Home /\n * End step, values snap to `step` and stay sorted, `inverted` / `dir` flip the direction,\n * `valueCommit` fires at the end of a slide or key step.\n *\n * `value` / `defaultValue` / `valueChange` (`[(value)]`, a `[min, max]` tuple) plus\n * NG_VALUE_ACCESSOR.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-range-slider, [ui-range-slider]',\n  standalone: true,\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiRangeSliderComponent), multi: true }],\n  host: {\n    '[attr.class]': '\"flex flex-col gap-2\"',\n    // id / class belong to the Radix root (React spreads them there).\n    '[attr.id]': 'null',\n  },\n  template: `\n    @if (label) {\n      <label [attr.for]=\"fieldId\" class=\"text-sm font-medium\">{{ label }}</label>\n    }\n    @if (hint && !hasError) {\n      <p [id]=\"fieldId + '-hint'\" class=\"text-muted-foreground text-xs\">{{ hint }}</p>\n    }\n    <div class=\"flex items-center gap-4\">\n      <div class=\"text-muted-foreground min-w-[3rem] text-sm tabular-nums\" aria-hidden=\"true\">\n        {{ valueAt(0, min) }}\n      </div>\n      <span\n        #root\n        [id]=\"fieldId\"\n        data-slot=\"range-slider\"\n        [attr.dir]=\"dir\"\n        data-orientation=\"horizontal\"\n        [attr.aria-disabled]=\"disabled\"\n        [attr.data-disabled]=\"disabled ? '' : null\"\n        [attr.aria-describedby]=\"describedBy\"\n        [attr.aria-invalid]=\"hasError || null\"\n        [class]=\"rootClass\"\n        (keydown)=\"onKeydown($event)\"\n        (pointerdown)=\"onPointerDown($event)\"\n        (pointermove)=\"onPointerMove($event)\"\n        (pointerup)=\"onPointerUp($event)\"\n      >\n        <span\n          data-uipkge=\"\"\n          data-slot=\"slider-track\"\n          data-orientation=\"horizontal\"\n          [attr.data-disabled]=\"disabled ? '' : null\"\n          [class]=\"trackClass\"\n        >\n          <span\n            data-uipkge=\"\"\n            data-slot=\"slider-range\"\n            data-orientation=\"horizontal\"\n            [attr.data-disabled]=\"disabled ? '' : null\"\n            [class]=\"rangeClass\"\n            [style]=\"rangeStyle\"\n          ></span>\n        </span>\n        @if (showTicks && ticks.length > 0) {\n          <div\n            class=\"pointer-events-none absolute top-1/2 right-0 left-0 flex -translate-y-1/2 justify-between\"\n            aria-hidden=\"true\"\n          >\n            @for (tick of ticks; track tick) {\n              <div class=\"bg-muted-foreground/30 h-2 w-0.5 rounded-full\"></div>\n            }\n          </div>\n        }\n        @for (thumb of values; track $index) {\n          <span class=\"absolute [transform:var(--radix-slider-thumb-transform)]\" [style]=\"thumbStyle(thumb)\">\n            <span\n              role=\"slider\"\n              data-uipkge=\"\"\n              data-slot=\"slider-thumb\"\n              [attr.aria-label]=\"thumbAriaLabel($index)\"\n              [attr.aria-valuemin]=\"min\"\n              [attr.aria-valuenow]=\"thumb\"\n              [attr.aria-valuemax]=\"max\"\n              aria-orientation=\"horizontal\"\n              data-orientation=\"horizontal\"\n              [attr.data-disabled]=\"disabled ? '' : null\"\n              [attr.tabindex]=\"disabled ? null : 0\"\n              [class]=\"thumbClass\"\n              (focus)=\"valueIndexToChange = $index\"\n              (blur)=\"onTouched()\"\n            >\n              @if (thumbLabel) {\n                <span\n                  class=\"bg-background absolute -top-6 left-1/2 -translate-x-1/2 rounded px-1 text-xs whitespace-nowrap\"\n                  >{{ formatThumb(valueAt($index, 0)) }}</span\n                >\n              }\n            </span>\n            @if (name) {\n              <input hidden [attr.name]=\"name + '[]'\" [value]=\"thumb\" />\n            }\n          </span>\n        }\n      </span>\n      <div class=\"text-muted-foreground min-w-[3rem] text-sm tabular-nums\" aria-hidden=\"true\">\n        {{ valueAt(1, max) }}\n      </div>\n    </div>\n    @if (showTicks && ticks.length > 0) {\n      <div class=\"text-muted-foreground flex justify-between px-1 text-xs\" aria-hidden=\"true\">\n        <span>{{ min }}</span>\n        <span>{{ max }}</span>\n      </div>\n    }\n    @if (hasError) {\n      <div [id]=\"fieldId + '-error'\" class=\"flex flex-col gap-0.5\" role=\"alert\">\n        @for (msg of errorList; track $index) {\n          <p class=\"text-destructive text-xs\">{{ msg }}</p>\n        }\n      </div>\n    }\n  `,\n})\nexport class UiRangeSliderComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {\n  private readonly cdr = inject(ChangeDetectorRef)\n  private readonly autoId = uniqueId('range-slider')\n  private readonly _value = signal<RangeSliderValue | undefined>(undefined)\n  private readonly _internal = signal<RangeSliderValue | null>(null)\n  private readonly thumbWidth = signal<number | null>(null)\n  private valuesBeforeSlide: number[] = []\n  private captureTarget: EventTarget | null = null\n  private formBound = false\n  private onChange: (v: RangeSliderValue) => void = () => {}\n  private ro?: ResizeObserver\n  valueIndexToChange = 0\n  onTouched: () => void = () => {}\n\n  /** Controlled value (React `value`). Reading it returns the current tuple. */\n  @Input()\n  set value(v: RangeSliderValue | null | undefined) {\n    this._value.set(v ?? undefined)\n  }\n  get value(): RangeSliderValue {\n    return this._value() ?? this._internal() ?? this.defaultValue ?? [this.min, this.max]\n  }\n  /** Uncontrolled initial value. */\n  @Input() defaultValue?: RangeSliderValue\n  @Output() valueChange = new EventEmitter<RangeSliderValue>()\n  /** Radix `onValueCommit`: end of a slide or a key step. */\n  @Output() valueCommit = new EventEmitter<RangeSliderValue>()\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: numberAttribute }) min = 0\n  @Input({ transform: numberAttribute }) max = 100\n  @Input({ transform: numberAttribute }) step = 1\n  @Input() label?: string\n  @Input() hint?: string\n  @Input() errorMessages?: string | string[]\n  @Input({ transform: booleanAttribute }) error = false\n  /** Track fill color. */\n  @Input() color: RangeSliderColor = 'primary'\n  @Input() thumbSize: RangeSliderThumbSize = 'md'\n  @Input() trackHeight: RangeSliderTrackHeight = 'md'\n  @Input({ transform: booleanAttribute }) showTicks = false\n  @Input({ transform: numberAttribute }) tickInterval?: number\n  /** Always-visible value bubbles above the thumbs. */\n  @Input({ transform: booleanAttribute }) thumbLabel = false\n  @Input() thumbLabelFormat?: (value: number) => string\n  /** Radix Root props (React spreads them onto the root). */\n  @Input({ transform: booleanAttribute }) inverted = false\n  @Input() dir: 'ltr' | 'rtl' = 'ltr'\n  @Input() name?: string\n  @Input({ transform: numberAttribute }) minStepsBetweenThumbs = 0\n  @Input() id?: string\n  @Input('class') className?: string\n\n  @ViewChild('root', { static: true }) rootRef!: ElementRef<HTMLElement>\n\n  get fieldId(): string {\n    return this.id || this.autoId\n  }\n\n  get values(): number[] {\n    return this.value\n  }\n\n  /** `value` can be shorter than the thumbs (e.g. still empty), so reads fall back explicitly. */\n  valueAt(index: number, fallback: number): number {\n    return this.values[index] ?? fallback\n  }\n\n  get hasError(): boolean {\n    const m = this.errorMessages\n    return this.error || Boolean(m && (typeof m === 'string' ? m : m.length > 0))\n  }\n\n  get errorList(): string[] {\n    const m = this.errorMessages\n    return !m ? [] : typeof m === 'string' ? [m] : m\n  }\n\n  get describedBy(): string | null {\n    if (this.hint && !this.hasError) return `${this.fieldId}-hint`\n    return this.hasError ? `${this.fieldId}-error` : null\n  }\n\n  get ticks(): number[] {\n    if (!this.showTicks || !this.tickInterval) return []\n    const result: number[] = []\n    for (let i = this.min; i <= this.max; i += this.tickInterval) result.push(i)\n    return result\n  }\n\n  private get slidingFromLeft(): boolean {\n    return (this.dir !== 'rtl') !== this.inverted\n  }\n\n  // ── classes (verbatim from React) ──\n  get rootClass(): string {\n    // Radix sets --radix-slider-thumb-transform inline on the root; same value as a utility.\n    return cn(\n      'relative flex w-full touch-none items-center select-none',\n      '[--radix-slider-thumb-transform:translateX(-50%)]',\n      this.className,\n    )\n  }\n  get trackClass(): string {\n    return cn('bg-muted relative w-full overflow-hidden rounded-full', trackHeightClasses[this.trackHeight])\n  }\n  get rangeClass(): string {\n    return cn('absolute h-full', colorClasses[this.color] || colorClasses['primary'])\n  }\n  get thumbClass(): string {\n    return cn(\n      'border-primary ring-ring/50 bg-background block rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',\n      thumbSizeClasses[this.thumbSize],\n      this.hasError && 'border-destructive',\n    )\n  }\n\n  // ── Radix positioning ──\n  get rangeStyle(): Record<string, string> {\n    const pcts = this.values.map((v) => toPercent(v, this.min, this.max))\n    const start = pcts.length > 1 ? Math.min(...pcts) : 0\n    const end = 100 - Math.max(...pcts)\n    const [s, e] = this.slidingFromLeft ? ['left', 'right'] : ['right', 'left']\n    return { [s]: `${start}%`, [e]: `${end}%` }\n  }\n  thumbStyle(value: number): Record<string, string> {\n    const pct = toPercent(value, this.min, this.max)\n    const size = this.thumbWidth()\n    const direction = this.slidingFromLeft ? 1 : -1\n    const offset = size ? thumbInBoundsOffset(size, pct, direction) : 0\n    return { [this.slidingFromLeft ? 'left' : 'right']: `calc(${pct}% + ${offset}px)` }\n  }\n\n  thumbAriaLabel(index: number): string {\n    const which = index === 0 ? 'minimum' : 'maximum'\n    return this.label ? `${this.label} ${which}` : index === 0 ? 'Minimum value' : 'Maximum value'\n  }\n\n  formatThumb(v: number): string | number {\n    return this.thumbLabelFormat ? this.thumbLabelFormat(v) : v\n  }\n\n  // ── value updates (Radix updateValues) ──\n  private updateValues(raw: number, atIndex: number, commit = false): void {\n    const snapped = roundValue(Math.round((raw - this.min) / this.step) * this.step + this.min, decimalCount(this.step))\n    const next = clamp(snapped, this.min, this.max)\n    const prev = this.values\n    const nextValues = [...prev]\n    nextValues[atIndex] = next\n    nextValues.sort((a, b) => a - b)\n    const minGap = this.minStepsBetweenThumbs * this.step\n    if (minGap > 0 && nextValues[1]! - nextValues[0]! < minGap) return\n    this.valueIndexToChange = nextValues.indexOf(next)\n    if (String(nextValues) === String(prev)) return\n    const tuple: RangeSliderValue = [nextValues[0]!, nextValues[1]!]\n    if (this._value() === undefined) this._internal.set(tuple)\n    else if (this.formBound) this._value.set(tuple)\n    this.thumbs()[this.valueIndexToChange]?.focus({ preventScroll: true })\n    this.valueChange.emit(tuple)\n    this.onChange(tuple)\n    if (commit) this.valueCommit.emit(tuple)\n  }\n\n  private thumbs(): HTMLElement[] {\n    return [...this.rootRef.nativeElement.querySelectorAll<HTMLElement>('[data-slot=\"slider-thumb\"]')]\n  }\n\n  private valueFromPointer(event: PointerEvent): number {\n    const rect = this.rootRef.nativeElement.getBoundingClientRect()\n    const out: [number, number] = this.slidingFromLeft ? [this.min, this.max] : [this.max, this.min]\n    return linearScale([0, rect.width], out)(event.clientX - rect.left)\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    if (this.disabled) return\n    const key = event.key\n    if (key === 'Home') {\n      this.updateValues(this.min, 0, true)\n      event.preventDefault()\n    } else if (key === 'End') {\n      this.updateValues(this.max, this.values.length - 1, true)\n      event.preventDefault()\n    } else if (PAGE_KEYS.includes(key) || ARROW_KEYS.includes(key)) {\n      const dir = BACK_KEYS[this.slidingFromLeft ? 'from-left' : 'from-right'].includes(key) ? -1 : 1\n      const skip = PAGE_KEYS.includes(key) || (event.shiftKey && ARROW_KEYS.includes(key))\n      const atIndex = this.valueIndexToChange\n      const current = this.values[atIndex] ?? this.min\n      this.updateValues(current + this.step * (skip ? 10 : 1) * dir, atIndex, true)\n      event.preventDefault()\n    }\n  }\n\n  onPointerDown(event: PointerEvent): void {\n    if (this.disabled) return\n    this.valuesBeforeSlide = this.values\n    const target = event.target as HTMLElement\n    target.setPointerCapture?.(event.pointerId)\n    this.captureTarget = target\n    event.preventDefault()\n    const thumbs = this.thumbs()\n    if (thumbs.includes(target)) {\n      this.valueIndexToChange = thumbs.indexOf(target)\n      target.focus({ preventScroll: true })\n    } else {\n      const v = this.valueFromPointer(event)\n      this.updateValues(v, closestIndex(this.values, v))\n    }\n  }\n\n  onPointerMove(event: PointerEvent): void {\n    if (this.disabled || !this.isCaptured(event)) return\n    this.updateValues(this.valueFromPointer(event), this.valueIndexToChange)\n  }\n\n  onPointerUp(event: PointerEvent): void {\n    if (this.disabled || !this.isCaptured(event)) return\n    const target = event.target as HTMLElement\n    target.releasePointerCapture?.(event.pointerId)\n    this.captureTarget = null\n    const i = this.valueIndexToChange\n    if (this.values[i] !== this.valuesBeforeSlide[i]) this.valueCommit.emit([this.values[0]!, this.values[1]!])\n    this.onTouched()\n  }\n\n  private isCaptured(event: PointerEvent): boolean {\n    const target = event.target as HTMLElement\n    return target.hasPointerCapture ? target.hasPointerCapture(event.pointerId) : this.captureTarget === target\n  }\n\n  ngAfterViewInit(): void {\n    if (typeof ResizeObserver === 'undefined') return\n    this.ro = new ResizeObserver(() => {\n      const t = this.thumbs()[0]\n      if (t) this.thumbWidth.set(t.offsetWidth)\n    })\n    this.ro.observe(this.rootRef.nativeElement)\n    const t = this.thumbs()[0]\n    if (t) this.ro.observe(t)\n  }\n\n  ngOnDestroy(): void {\n    this.ro?.disconnect()\n  }\n\n  // ── ControlValueAccessor ──\n  writeValue(v: RangeSliderValue | null): void {\n    this._value.set(v ?? undefined)\n    this.cdr.markForCheck()\n  }\n  registerOnChange(fn: (v: RangeSliderValue) => void): void {\n    this.formBound = true\n    this.onChange = 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/range-slider/range-slider.component.ts"
    },
    {
      "path": "packages/registry-angular/components/range-slider/index.ts",
      "content": "export {\n  UiRangeSliderComponent,\n  type RangeSliderValue,\n  type RangeSliderColor,\n  type RangeSliderThumbSize,\n  type RangeSliderTrackHeight,\n} from './range-slider.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/range-slider/index.ts"
    }
  ],
  "dependencies": [
    "@angular/forms"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Two-thumb range slider for \"between X and Y\" inputs — price filters, age ranges, time windows. Radix Slider semantics (pointer drag, keyboard steps, inverted), with label, hint, ticks, thumb value bubbles, colors, sizes and error state.",
  "categories": [
    "form"
  ]
}