{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "range-slider",
  "title": "Range Slider",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/range-slider/RangeSlider.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface RangeSliderProps extends HTMLAttributes<HTMLDivElement> {\n    /** The controlled value. Bindable `[min, max]` tuple. */\n    value?: [number, number]\n    /** Initial value for uncontrolled use. */\n    defaultValue?: [number, number]\n    /** Called with the new tuple after every commit. */\n    onValueChange?: (value: [number, number]) => void\n    /** When `true`, prevents the user from interacting with the range slider */\n    disabled?: boolean\n    /** Minimum value */\n    min?: number\n    /** Maximum value */\n    max?: number\n    /** Step value */\n    step?: number\n    /** Label for the range slider */\n    label?: string\n    /** Hint text for the range slider */\n    hint?: string\n    /** Error messages to display */\n    errorMessages?: string | string[]\n    /** Whether to show error state */\n    error?: boolean\n    /** Custom color for the track fill */\n    color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string\n    /** Thumb size */\n    thumbSize?: 'sm' | 'md' | 'lg'\n    /** Track height */\n    trackHeight?: 'sm' | 'md' | 'lg'\n    /** Show ticks */\n    showTicks?: boolean\n    /** Tick interval */\n    tickInterval?: number\n    /** Show thumb labels */\n    thumbLabel?: boolean\n    /** Invert the slider */\n    inverted?: boolean\n    /** Format thumb label */\n    thumbLabelFormat?: (value: number) => string\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value = $bindable(),\n    defaultValue = undefined,\n    onValueChange,\n    disabled = false,\n    min = 0,\n    max = 100,\n    step = 1,\n    label = undefined,\n    hint = undefined,\n    errorMessages = undefined,\n    error = false,\n    color = 'primary',\n    thumbSize = 'md',\n    trackHeight = 'md',\n    showTicks = false,\n    tickInterval = undefined,\n    thumbLabel = false,\n    inverted = false,\n    thumbLabelFormat = undefined,\n    ref = $bindable(null),\n    ...restProps\n  }: RangeSliderProps = $props()\n\n  const fieldId = $props.id()\n\n  let track: HTMLDivElement | null = null\n  // Uncontrolled seed: intentionally the initial `defaultValue` (or full span) only.\n  // svelte-ignore state_referenced_locally\n  let internal = $state<[number, number]>(defaultValue ?? [min, max])\n  let dragging = $state<0 | 1 | null>(null)\n\n  const current = $derived(value ?? internal)\n\n  function clampTuple([lo, hi]: [number, number]): [number, number] {\n    const quantize = (v: number) => {\n      if (!Number.isFinite(step) || step <= 0) return Math.min(Math.max(v, min), max)\n      const snapped = min + Math.round((v - min) / step) * step\n      // Round away float artifacts from fractional steps (0.1 + 0.2 != 0.3).\n      const precision = Math.max(0, -Math.floor(Math.log10(step)))\n      return Math.min(Math.max(Number(snapped.toFixed(precision)), min), max)\n    }\n    const q0 = quantize(lo)\n    const q1 = quantize(hi)\n    return q0 <= q1 ? [q0, q1] : [q1, q0]\n  }\n\n  function commit(next: [number, number]) {\n    const clamped = clampTuple(next)\n    internal = clamped\n    value = clamped\n    onValueChange?.(clamped)\n  }\n\n  /** Raw 0..100 position of a value along the track (pre-inversion). */\n  function percentOf(v: number): number {\n    if (max === min) return 0\n    return ((v - min) / (max - min)) * 100\n  }\n\n  /** Visual left% for a value, honoring `inverted`. */\n  function visualPercent(v: number): number {\n    const p = percentOf(v)\n    return inverted ? 100 - p : p\n  }\n\n  const rangeStyle = $derived.by(() => {\n    const a = visualPercent(current[0])\n    const b = visualPercent(current[1])\n    const left = Math.min(a, b)\n    return `left: ${left}%; width: ${Math.abs(b - a)}%`\n  })\n\n  function valueFromClientX(clientX: number): number {\n    const rect = track?.getBoundingClientRect()\n    if (!rect || rect.width === 0) return min\n    const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1)\n    return min + (inverted ? 1 - ratio : ratio) * (max - min)\n  }\n\n  function onTrackPointerDown(event: PointerEvent) {\n    if (disabled) return\n    // Jump the nearest thumb to the click, then drag it.\n    const v = valueFromClientX(event.clientX)\n    const dist0 = Math.abs(v - current[0])\n    const dist1 = Math.abs(v - current[1])\n    const index: 0 | 1 = dist0 <= dist1 ? 0 : 1\n    event.preventDefault()\n    ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)\n    dragging = index\n    const next: [number, number] = index === 0 ? [v, current[1]] : [current[0], v]\n    commit(next)\n  }\n\n  function onThumbPointerDown(event: PointerEvent, index: 0 | 1) {\n    if (disabled) return\n    event.preventDefault()\n    event.stopPropagation()\n    ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)\n    dragging = index\n  }\n\n  function onPointerMove(event: PointerEvent) {\n    if (dragging === null || disabled) return\n    const v = valueFromClientX(event.clientX)\n    const next: [number, number] =\n      dragging === 0 ? [Math.min(v, current[1]), current[1]] : [current[0], Math.max(v, current[0])]\n    commit(next)\n  }\n\n  function onPointerUp() {\n    dragging = null\n  }\n\n  function onThumbKeydown(event: KeyboardEvent, index: 0 | 1) {\n    if (disabled) return\n    const page = step * 10\n    let v: number | null = null\n    if (event.key === 'ArrowRight' || event.key === 'ArrowUp') v = current[index] + step\n    else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') v = current[index] - step\n    else if (event.key === 'PageUp') v = current[index] + page\n    else if (event.key === 'PageDown') v = current[index] - page\n    else if (event.key === 'Home') v = index === 0 ? min : current[0]\n    else if (event.key === 'End') v = index === 1 ? max : current[1]\n    else return\n    event.preventDefault()\n    const next: [number, number] =\n      index === 0 ? [Math.min(v, current[1]), current[1]] : [current[0], Math.max(v, current[0])]\n    commit(next)\n  }\n\n  // Color classes\n  const 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\n  // Track height classes\n  const trackHeightClasses = {\n    sm: 'h-1',\n    md: 'h-1.5',\n    lg: 'h-2',\n  }\n\n  // Thumb size classes\n  const thumbSizeClasses = {\n    sm: 'size-3',\n    md: 'size-4',\n    lg: 'size-5',\n  }\n\n  // Generate ticks\n  const ticks = $derived.by(() => {\n    if (!showTicks || !tickInterval) return []\n    const result: number[] = []\n    for (let i = min; i <= max; i += tickInterval) {\n      result.push(i)\n    }\n    return result\n  })\n\n  const hasError = $derived.by(() => {\n    if (error) return true\n    if (errorMessages && (typeof errorMessages === 'string' ? errorMessages : errorMessages.length > 0)) return true\n    return false\n  })\n\n  const describedBy = $derived(\n    hint && !hasError ? `${fieldId}-hint` : hasError ? `${fieldId}-error` : undefined,\n  )\n</script>\n\n<div bind:this={ref} data-uipkge=\"\" data-slot=\"range-slider\" class={cn('flex flex-col gap-2', className)} {...restProps}>\n  {#if label}\n    <span id={fieldId} class=\"text-sm font-medium\">\n      {label}\n    </span>\n  {/if}\n\n  {#if hint && !hasError}\n    <p id=\"{fieldId}-hint\" class=\"text-muted-foreground text-xs\">\n      {hint}\n    </p>\n  {/if}\n\n  <div class=\"flex items-center gap-4\">\n    <!-- Min value display -->\n    <div class=\"text-muted-foreground min-w-[3rem] text-sm tabular-nums\" aria-hidden=\"true\">\n      {current[0]}\n    </div>\n\n    <!-- svelte-ignore a11y_no_noninteractive_element_interactions: hand-rolled slider track; thumbs own keyboard + ARIA -->\n    <!-- svelte-ignore a11y_role_supports_aria_props: aria-invalid mirrors the Vue twin's error state -->\n    <div\n      bind:this={track}\n      role=\"group\"\n      aria-labelledby={label ? fieldId : undefined}\n      aria-describedby={describedBy}\n      aria-invalid={hasError || undefined}\n      aria-disabled={disabled || undefined}\n      data-orientation=\"horizontal\"\n      data-disabled={disabled || undefined}\n      class=\"relative flex w-full touch-none items-center select-none\"\n      onpointerdown={onTrackPointerDown}\n      onpointermove={onPointerMove}\n      onpointerup={onPointerUp}\n      onpointercancel={onPointerUp}\n    >\n      <div\n        data-uipkge=\"\"\n        data-slot=\"slider-track\"\n        class={cn('bg-muted relative w-full overflow-hidden rounded-full', trackHeightClasses[trackHeight])}\n      >\n        <div\n          data-uipkge=\"\"\n          data-slot=\"slider-range\"\n          style={rangeStyle}\n          class={cn('absolute h-full', colorClasses[color] || colorClasses.primary)}\n        ></div>\n      </div>\n\n      <!-- Ticks -->\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          {#each ticks as tick (tick)}\n            <div class=\"bg-muted-foreground/30 h-2 w-0.5 rounded-full\"></div>\n          {/each}\n        </div>\n      {/if}\n\n      <!-- Start Thumb (Min) -->\n      <!-- svelte-ignore a11y_no_noninteractive_tabindex: role=\"slider\" is the interactive thumb -->\n      <div\n        role=\"slider\"\n        tabindex={disabled ? -1 : 0}\n        data-uipkge=\"\"\n        data-slot=\"slider-thumb\"\n        aria-label={label ? `${label} minimum` : 'Minimum value'}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={current[0]}\n        aria-valuetext={thumbLabelFormat ? thumbLabelFormat(current[0]) : String(current[0])}\n        aria-disabled={disabled || undefined}\n        style=\"left: {visualPercent(current[0])}%; translate: -50% 0;\"\n        class={cn(\n          'border-primary ring-ring/50 bg-background absolute 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[thumbSize],\n          disabled && 'pointer-events-none opacity-50',\n          hasError && 'border-destructive',\n        )}\n        onpointerdown={(e) => onThumbPointerDown(e, 0)}\n        onkeydown={(e) => onThumbKeydown(e, 0)}\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          >\n            {thumbLabelFormat ? thumbLabelFormat(current[0]) : current[0]}\n          </span>\n        {/if}\n      </div>\n\n      <!-- End Thumb (Max) -->\n      <!-- svelte-ignore a11y_no_noninteractive_tabindex: role=\"slider\" is the interactive thumb -->\n      <div\n        role=\"slider\"\n        tabindex={disabled ? -1 : 0}\n        data-uipkge=\"\"\n        data-slot=\"slider-thumb\"\n        aria-label={label ? `${label} maximum` : 'Maximum value'}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={current[1]}\n        aria-valuetext={thumbLabelFormat ? thumbLabelFormat(current[1]) : String(current[1])}\n        aria-disabled={disabled || undefined}\n        style=\"left: {visualPercent(current[1])}%; translate: -50% 0;\"\n        class={cn(\n          'border-primary ring-ring/50 bg-background absolute 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[thumbSize],\n          disabled && 'pointer-events-none opacity-50',\n          hasError && 'border-destructive',\n        )}\n        onpointerdown={(e) => onThumbPointerDown(e, 1)}\n        onkeydown={(e) => onThumbKeydown(e, 1)}\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          >\n            {thumbLabelFormat ? thumbLabelFormat(current[1]) : current[1]}\n          </span>\n        {/if}\n      </div>\n    </div>\n\n    <!-- Max value display -->\n    <div class=\"text-muted-foreground min-w-[3rem] text-sm tabular-nums\" aria-hidden=\"true\">\n      {current[1]}\n    </div>\n  </div>\n\n  <!-- Tick labels -->\n  {#if showTicks && ticks.length > 0}\n    <div class=\"text-muted-foreground flex justify-between px-1 text-xs\">\n      <span>{min}</span>\n      <span>{max}</span>\n    </div>\n  {/if}\n\n  {#if hasError}\n    <div id=\"{fieldId}-error\" class=\"flex flex-col gap-0.5\" role=\"alert\">\n      {#if typeof errorMessages === 'string'}\n        <p class=\"text-destructive text-xs\">\n          {errorMessages}\n        </p>\n      {:else}\n        {#each errorMessages ?? [] as msg, i (i)}\n          <p class=\"text-destructive text-xs\">\n            {msg}\n          </p>\n        {/each}\n      {/if}\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/range-slider/RangeSlider.svelte"
    },
    {
      "path": "packages/registry-svelte/components/range-slider/index.ts",
      "content": "export { default as RangeSlider, type RangeSliderProps } from './RangeSlider.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/range-slider/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Two-thumb range slider for \"between X and Y\" inputs — price filters, age ranges, time windows. Hand-rolled runes implementation with pointer dragging, keyboard handling, and aria-valuetext.",
  "categories": [
    "form"
  ]
}