{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "range-slider",
  "title": "Range Slider",
  "type": "registry:ui",
  "description": "Two-thumb range slider for \"between X and Y\" inputs — price filters, age ranges, time windows. Built on @radix-ui/react-slider with proper keyboard handling and aria-valuetext.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/range-slider/range-slider.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nconst numberList = {\n  fromAttribute: (v: string | null): [number, number] | undefined => {\n    if (!v) return undefined\n    const parts = v\n      .replace(/[[\\]\\s]/g, '')\n      .split(',')\n      .filter(Boolean)\n      .map(Number)\n    if (parts.length >= 2) return [parts[0]!, parts[1]!]\n    return undefined\n  },\n  toAttribute: (v: [number, number] | undefined): string | null => (v ? `${v[0]},${v[1]}` : null),\n}\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 = {\n  sm: 'h-1',\n  md: 'h-1.5',\n  lg: 'h-2',\n}\n\nconst thumbSizeClasses = {\n  sm: 'size-3',\n  md: 'size-4',\n  lg: 'size-5',\n}\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v))\n}\n\n/**\n * <uip-range-slider> — the registry RangeSlider as a web component.\n *\n * Form-associated: submits `name[]=min&name[]=max` with its <form>.\n */\nexport class UipRangeSlider extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; width: 100%; }`]\n\n  static properties = {\n    value: { converter: numberList },\n    defaultValue: { attribute: 'default-value', converter: numberList },\n    min: { type: Number },\n    max: { type: Number },\n    step: { type: Number },\n    disabled: { type: Boolean, reflect: true },\n    label: {},\n    hint: {},\n    error: { type: Boolean, reflect: true },\n    errorMessages: { attribute: 'error-messages' },\n    color: {},\n    thumbSize: { attribute: 'thumb-size' },\n    trackHeight: { attribute: 'track-height' },\n    showTicks: { type: Boolean, attribute: 'show-ticks' },\n    tickInterval: { type: Number, attribute: 'tick-interval' },\n    thumbLabel: { type: Boolean, attribute: 'thumb-label' },\n    inverted: { type: Boolean, reflect: true },\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    activeThumb: { state: true },\n    isDragging: { state: true },\n  }\n\n  value?: [number, number]\n  defaultValue?: [number, number]\n  min = 0\n  max = 100\n  step = 1\n  disabled = false\n  label?: string\n  hint?: string\n  error = false\n  errorMessages?: string\n  color = 'primary'\n  thumbSize: 'sm' | 'md' | 'lg' = 'md'\n  trackHeight: 'sm' | 'md' | 'lg' = 'md'\n  showTicks = false\n  tickInterval?: number\n  thumbLabel = false\n  inverted = false\n  name?: string\n  accessibleLabel?: string\n\n  private activeThumb = 0\n  private isDragging = false\n  private initialValue: [number, number] = [0, 100]\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', 'range-slider')\n    if (this.value === undefined) {\n      this.value = this.defaultValue ?? [this.min, this.max]\n    }\n    this.initialValue = [...this.value]\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (this.value === undefined) {\n      this.value = this.defaultValue ?? [this.min, this.max]\n    }\n    if (changed.has('value') || changed.has('name')) {\n      const vals = this.currentValue\n      if (!this.name) {\n        this.internals.setFormValue(null)\n      } else {\n        const fd = new FormData()\n        fd.append(`${this.name}[]`, String(vals[0]))\n        fd.append(`${this.name}[]`, String(vals[1]))\n        this.internals.setFormValue(fd)\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.commit([...this.initialValue])\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get currentValue(): [number, number] {\n    return this.value ?? [this.min, this.max]\n  }\n\n  private commit(next: [number, number]) {\n    const prev = this.currentValue\n    this.value = next\n    if (next[0] !== prev[0] || next[1] !== prev[1]) {\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\n  private percent(v: number): number {\n    return clamp(((v - this.min) / (this.max - this.min)) * 100, 0, 100)\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  private get track(): HTMLElement | null {\n    return this.renderRoot?.querySelector('[data-slot=slider-track]')\n  }\n\n  private getValueFromPointer(e: PointerEvent): number {\n    const track = this.track\n    if (!track) return this.min\n    const rect = track.getBoundingClientRect()\n    const fraction = clamp((e.clientX - rect.left) / rect.width, 0, 1)\n    const raw = this.min + fraction * (this.max - this.min)\n    return this.snap(raw)\n  }\n\n  private onPointerDown = (e: PointerEvent) => {\n    if (this.disabled || e.button !== 0) return\n    const target = e.currentTarget as HTMLElement\n    target.setPointerCapture(e.pointerId)\n    this.isDragging = true\n\n    const clickedVal = this.getValueFromPointer(e)\n    const [lo, hi] = this.currentValue\n    const distLo = Math.abs(clickedVal - lo)\n    const distHi = Math.abs(clickedVal - hi)\n\n    if (distLo <= distHi) {\n      this.activeThumb = 0\n      this.commit([Math.min(clickedVal, hi), hi])\n    } else {\n      this.activeThumb = 1\n      this.commit([lo, Math.max(clickedVal, lo)])\n    }\n  }\n\n  private onPointerMove = (e: PointerEvent) => {\n    if (!this.isDragging) return\n    const val = this.getValueFromPointer(e)\n    const [lo, hi] = this.currentValue\n    if (this.activeThumb === 0) {\n      this.commit([Math.min(val, hi), hi])\n    } else {\n      this.commit([lo, Math.max(val, lo)])\n    }\n  }\n\n  private onPointerUp = (e: PointerEvent) => {\n    if (!this.isDragging) return\n    this.isDragging = false\n    const target = e.currentTarget as HTMLElement\n    if (target.hasPointerCapture(e.pointerId)) {\n      target.releasePointerCapture(e.pointerId)\n    }\n  }\n\n  private onKeyDownThumb(index: 0 | 1, e: KeyboardEvent) {\n    if (this.disabled) return\n    const [lo, hi] = this.currentValue\n    const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? this.step : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -this.step : 0\n    if (delta !== 0) {\n      e.preventDefault()\n      if (index === 0) {\n        this.commit([clamp(lo + delta, this.min, hi), hi])\n      } else {\n        this.commit([lo, clamp(hi + delta, lo, this.max)])\n      }\n    }\n  }\n\n  render() {\n    const [lo, hi] = this.currentValue\n    const pctLo = this.percent(lo)\n    const pctHi = this.percent(hi)\n\n    const hasError = this.error || !!this.errorMessages\n\n    const thumbClass = cn(\n      'border-primary ring-ring/50 bg-background block rounded-full border shadow-xs transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 select-none cursor-pointer',\n      thumbSizeClasses[this.thumbSize],\n      hasError && 'border-destructive',\n    )\n\n    const ticks: number[] = []\n    if (this.showTicks && this.tickInterval) {\n      for (let i = this.min; i <= this.max; i += this.tickInterval) {\n        ticks.push(i)\n      }\n    }\n\n    const rangeStart = this.inverted ? 0 : pctLo\n    const rangeWidth = this.inverted ? pctLo : pctHi - pctLo\n\n    return html`\n      <div part=\"base\" class=\"flex flex-col gap-2\">\n        ${this.label\n          ? html`<label class=\"text-sm font-medium leading-none\">${this.label}</label>`\n          : nothing}\n        ${this.hint && !hasError\n          ? html`<p class=\"text-muted-foreground text-xs\">${this.hint}</p>`\n          : nothing}\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            ${lo}\n          </div>\n\n          <div\n            class=\"relative flex w-full touch-none items-center select-none py-4\"\n            @pointerdown=${this.onPointerDown}\n            @pointermove=${this.onPointerMove}\n            @pointerup=${this.onPointerUp}\n          >\n            <div\n              data-slot=\"slider-track\"\n              class=${cn(\n                'bg-muted relative w-full overflow-hidden rounded-full',\n                trackHeightClasses[this.trackHeight],\n              )}\n            >\n              <div\n                data-slot=\"slider-range\"\n                class=${cn('absolute h-full', colorClasses[this.color] || colorClasses.primary)}\n                style=${styleMap({\n                  left: `${rangeStart}%`,\n                  width: `${rangeWidth}%`,\n                })}\n              ></div>\n            </div>\n\n            ${this.showTicks && ticks.length > 0\n              ? html`\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                    ${ticks.map(\n                      () => html`<div class=\"bg-muted-foreground/30 h-2 w-0.5 rounded-full\"></div>`,\n                    )}\n                  </div>\n                `\n              : nothing}\n\n            <!-- Thumb 0 (Min) -->\n            <div\n              role=\"slider\"\n              tabindex=${this.disabled ? -1 : 0}\n              aria-label=${this.label ? `${this.label} minimum` : 'Minimum value'}\n              aria-valuemin=${this.min}\n              aria-valuemax=${hi}\n              aria-valuenow=${lo}\n              aria-disabled=${this.disabled ? 'true' : 'false'}\n              data-slot=\"slider-thumb\"\n              class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2\"\n              style=${styleMap({ left: `${pctLo}%` })}\n              @keydown=${(e: KeyboardEvent) => this.onKeyDownThumb(0, e)}\n            >\n              <div class=${thumbClass}></div>\n              ${this.thumbLabel\n                ? html`\n                    <span\n                      class=\"bg-background border border-border absolute -top-7 left-1/2 -translate-x-1/2 rounded px-1 text-xs whitespace-nowrap shadow-xs tabular-nums\"\n                    >\n                      ${lo}\n                    </span>\n                  `\n                : nothing}\n            </div>\n\n            <!-- Thumb 1 (Max) -->\n            <div\n              role=\"slider\"\n              tabindex=${this.disabled ? -1 : 0}\n              aria-label=${this.label ? `${this.label} maximum` : 'Maximum value'}\n              aria-valuemin=${lo}\n              aria-valuemax=${this.max}\n              aria-valuenow=${hi}\n              aria-disabled=${this.disabled ? 'true' : 'false'}\n              data-slot=\"slider-thumb\"\n              class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2\"\n              style=${styleMap({ left: `${pctHi}%` })}\n              @keydown=${(e: KeyboardEvent) => this.onKeyDownThumb(1, e)}\n            >\n              <div class=${thumbClass}></div>\n              ${this.thumbLabel\n                ? html`\n                    <span\n                      class=\"bg-background border border-border absolute -top-7 left-1/2 -translate-x-1/2 rounded px-1 text-xs whitespace-nowrap shadow-xs tabular-nums\"\n                    >\n                      ${hi}\n                    </span>\n                  `\n                : nothing}\n            </div>\n          </div>\n\n          <div class=\"text-muted-foreground min-w-[3rem] text-sm tabular-nums text-right\" aria-hidden=\"true\">\n            ${hi}\n          </div>\n        </div>\n\n        ${this.showTicks && ticks.length > 0\n          ? html`\n              <div class=\"text-muted-foreground flex justify-between px-1 text-xs\" aria-hidden=\"true\">\n                <span>${this.min}</span>\n                <span>${this.max}</span>\n              </div>\n            `\n          : nothing}\n        ${hasError && this.errorMessages\n          ? html`\n              <div class=\"flex flex-col gap-0.5\" role=\"alert\">\n                <p class=\"text-destructive text-xs\">${this.errorMessages}</p>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-range-slider') || customElements.define('uip-range-slider', UipRangeSlider)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-range-slider': UipRangeSlider\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/range-slider/range-slider.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
