{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dual-range-slider",
  "type": "registry:ui",
  "title": "Dual Range Slider",
  "description": "A two-thumb range slider with an optional histogram, value bubbles, a minimum gap and full keyboard support.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/dual-range-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type RangeValue = [number, number];\n\nexport interface DualRangeSliderProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Visible label and the base of each thumb's accessible name. */\n  label: string;\n  /** Hide the label row visually (it stays available to screen readers). */\n  hideLabel?: boolean;\n  /** Current range (controlled). */\n  value?: RangeValue;\n  /** Initial range (uncontrolled). */\n  defaultValue?: RangeValue;\n  /** Called on every change of either end. */\n  onValueChange?: (value: RangeValue) => void;\n  /** Called when a drag ends, or after a keyboard change. */\n  onValueCommit?: (value: RangeValue) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Smallest allowed gap between the two thumbs. */\n  minDistance?: number;\n  /** What happens when a thumb runs into the other one: stop there, or push it along. */\n  collision?: \"block\" | \"push\";\n  /** Optional distribution drawn above the track; bars inside the range are highlighted. */\n  histogram?: number[];\n  /** Height of the histogram in px. */\n  histogramHeight?: number;\n  /** Turns a value into text for bubbles, the readout and aria-valuetext (e.g. currency). */\n  formatValue?: (value: number) => string;\n  /** When the value bubbles above the thumbs are shown. */\n  bubble?: \"always\" | \"active\" | \"off\";\n  /** Show the formatted min and max under the track. */\n  showBounds?: boolean;\n  /** Fill, highlighted bars and focus colour. */\n  accent?: string;\n  disabled?: boolean;\n  /**\n   * Submits the range with a form: two hidden inputs share this name (min first),\n   * so `formData.getAll(name)` returns `[min, max]`.\n   */\n  name?: string;\n}\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\n/** Thumb radius in px: thumbs travel inside the track so they never overhang the ends. */\nconst THUMB_R = 10;\n\nfunction decimalsOf(step: number) {\n  const s = String(step);\n  return s.includes(\".\") ? s.length - s.indexOf(\".\") - 1 : 0;\n}\n\nconst defaultFormat = (v: number) => String(v);\n\n/**\n * A two-thumb range slider with an optional histogram. Each thumb is a WAI-ARIA slider whose\n * bounds follow the other thumb; the track handles pointer input with capture, so pressing\n * anywhere moves the nearest thumb and keeps dragging it. Thumbs never cross: depending on\n * `collision` they either stop at `minDistance` from each other or push the other thumb along.\n */\nexport function DualRangeSlider({\n  label,\n  hideLabel = false,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step = 1,\n  minDistance = 0,\n  collision = \"block\",\n  histogram,\n  histogramHeight = 48,\n  formatValue = defaultFormat,\n  bubble = \"active\",\n  showBounds = true,\n  accent = \"#FF7448\",\n  disabled = false,\n  name,\n  className = \"\",\n  style,\n  ...props\n}: DualRangeSliderProps) {\n  const labelId = React.useId();\n  const range = max - min || 1;\n  const gap = clamp(minDistance, 0, range);\n  const decimals = decimalsOf(step);\n\n  const snap = (raw: number) => {\n    const stepped = Math.round((raw - min) / step) * step + min;\n    return clamp(Number(stepped.toFixed(decimals)), min, max);\n  };\n\n  /** Puts a pair into a valid state: ordered, on the step grid and at least `gap` apart. */\n  const normalise = (pair: RangeValue): RangeValue => {\n    let lo = snap(Math.min(pair[0], pair[1]));\n    let hi = snap(Math.max(pair[0], pair[1]));\n    if (hi - lo < gap) {\n      hi = clamp(lo + gap, min, max);\n      lo = clamp(hi - gap, min, max);\n    }\n    return [lo, hi];\n  };\n\n  const [internal, setInternal] = React.useState<RangeValue>(() =>\n    normalise(defaultValue ?? [min + range * 0.25, min + range * 0.75]),\n  );\n  const [lo, hi] = normalise(valueProp ?? internal);\n\n  const [dragging, setDragging] = React.useState<0 | 1 | null>(null);\n  const [focused, setFocused] = React.useState<0 | 1 | null>(null);\n  const [trackWidth, setTrackWidth] = React.useState(0);\n\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const activeThumb = React.useRef<0 | 1 | null>(null);\n  const latest = React.useRef<RangeValue>([lo, hi]);\n\n  // The bubbles merge when they would overlap, so the track width is needed (measured, not per move).\n  React.useEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setTrackWidth(entry.contentRect.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const commit = (next: RangeValue) => {\n    latest.current = next;\n    if (next[0] === lo && next[1] === hi) return;\n    if (valueProp === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  /** Moves one thumb to `raw`, honouring step, bounds, minDistance and the collision mode. */\n  const moveThumb = (index: 0 | 1, raw: number): RangeValue => {\n    const v = snap(raw);\n    if (index === 0) {\n      if (collision === \"push\") {\n        const nextLo = Math.min(v, max - gap);\n        return [nextLo, Math.max(hi, snap(nextLo + gap))];\n      }\n      return [Math.min(v, hi - gap), hi];\n    }\n    if (collision === \"push\") {\n      const nextHi = Math.max(v, min + gap);\n      return [Math.min(lo, snap(nextHi - gap)), nextHi];\n    }\n    return [lo, Math.max(v, lo + gap)];\n  };\n\n  const valueAt = (clientX: number) => {\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return null;\n    const ratio = (clientX - rect.left - THUMB_R) / Math.max(rect.width - THUMB_R * 2, 1);\n    return min + clamp(ratio, 0, 1) * range;\n  };\n\n  const endDrag = () => {\n    if (activeThumb.current === null) return;\n    activeThumb.current = null;\n    setDragging(null);\n    onValueCommit?.(latest.current);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0) return;\n    const raw = valueAt(event.clientX);\n    if (raw === null) return;\n    event.preventDefault();\n    // Pressing a thumb grabs that thumb; pressing the track grabs the nearest one.\n    const hitThumb = thumbRefs.current.findIndex((t) => t?.contains(event.target as Node));\n    let index: 0 | 1;\n    if (hitThumb === 0 || hitThumb === 1) index = hitThumb;\n    else if (lo === hi) index = raw > hi ? 1 : 0;\n    else index = Math.abs(raw - lo) <= Math.abs(raw - hi) ? 0 : 1;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    thumbRefs.current[index]?.focus({ preventScroll: true });\n    activeThumb.current = index;\n    latest.current = [lo, hi];\n    setDragging(index);\n    if (hitThumb === -1) commit(moveThumb(index, raw));\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const index = activeThumb.current;\n    if (index === null) return;\n    const raw = valueAt(event.clientX);\n    // commit() only re-renders when the stepped value actually changes.\n    if (raw !== null) commit(moveThumb(index, raw));\n  };\n\n  const handleKeyDown = (index: 0 | 1) => (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const current = index === 0 ? lo : hi;\n    const big = Math.max(step, snap(min + range / 10) - min);\n    let next: number;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = current + (event.shiftKey ? big : step);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = current - (event.shiftKey ? big : step);\n        break;\n      case \"PageUp\":\n        next = current + big;\n        break;\n      case \"PageDown\":\n        next = current - big;\n        break;\n      case \"Home\":\n        next = min;\n        break;\n      case \"End\":\n        next = max;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    const pair = moveThumb(index, next);\n    commit(pair);\n    onValueCommit?.(pair);\n  };\n\n  const fraction = (v: number) => (v - min) / range;\n  const position = (v: number) => `calc(${THUMB_R}px + (100% - ${THUMB_R * 2}px) * ${fraction(v)})`;\n  const loText = formatValue(lo);\n  const hiText = formatValue(hi);\n\n  const bubbleVisible = (index: 0 | 1) =>\n    bubble === \"always\" || (bubble === \"active\" && (dragging === index || focused === index));\n  // When the two bubbles would collide, show one combined bubble centred between the thumbs.\n  const usable = Math.max(trackWidth - THUMB_R * 2, 1);\n  const bubblesCollide = trackWidth > 0 && (fraction(hi) - fraction(lo)) * usable < 72;\n  const merged = bubblesCollide && (bubbleVisible(0) || bubbleVisible(1));\n\n  const bars = histogram?.length ? histogram : null;\n  const peak = bars ? Math.max(...bars, 0) || 1 : 1;\n\n  const thumb = (index: 0 | 1) => {\n    const v = index === 0 ? lo : hi;\n    const text = index === 0 ? loText : hiText;\n    // In \"block\" mode each thumb's bounds follow the other thumb (APG multi-thumb slider).\n    const blocked = collision === \"block\";\n    return (\n      <div className=\"pointer-events-none absolute top-1/2\" style={{ left: position(v), zIndex: dragging === index ? 3 : 2 }}>\n        {!merged && (\n          <div\n            aria-hidden=\"true\"\n            className={`absolute bottom-full left-0 mb-4 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm transition-[opacity,translate] duration-150 motion-reduce:transition-none dark:bg-white dark:text-zinc-900 ${\n              bubbleVisible(index) ? \"translate-y-0 opacity-100\" : \"translate-y-1 opacity-0\"\n            }`}\n          >\n            {text}\n          </div>\n        )}\n        <div\n          ref={(el) => {\n            thumbRefs.current[index] = el;\n          }}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={`${label}, ${index === 0 ? \"minimum\" : \"maximum\"}`}\n          aria-valuemin={index === 0 ? min : blocked ? lo + gap : min + gap}\n          aria-valuemax={index === 1 ? max : blocked ? hi - gap : max - gap}\n          aria-valuenow={v}\n          aria-valuetext={text}\n          aria-orientation=\"horizontal\"\n          aria-disabled={disabled || undefined}\n          onKeyDown={handleKeyDown(index)}\n          onFocus={(event) => setFocused(event.currentTarget.matches(\":focus-visible\") ? index : null)}\n          onBlur={() => setFocused(null)}\n          className={`pointer-events-auto absolute left-0 top-0 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/15 bg-white shadow-[0_1px_3px_rgb(0_0_0/0.18)] outline-none ring-[var(--drs-accent)] ring-offset-2 ring-offset-white transition-transform duration-150 focus-visible:ring-2 motion-reduce:transition-none dark:border-white/20 dark:bg-zinc-100 dark:ring-offset-[#131619] ${\n            dragging === index ? \"scale-110\" : \"\"\n          } ${disabled ? \"cursor-not-allowed\" : \"cursor-grab active:cursor-grabbing\"}`}\n        >\n          <span aria-hidden=\"true\" className=\"absolute inset-[6px] rounded-full bg-[var(--drs-accent)]\" />\n        </div>\n      </div>\n    );\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={labelId}\n      className={`flex w-full select-none flex-col gap-2 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--drs-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className={hideLabel ? \"sr-only\" : \"flex items-baseline justify-between gap-3\"}>\n        <span id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </span>\n        <span aria-hidden=\"true\" className=\"font-mono text-xs tabular-nums text-zinc-900 dark:text-zinc-100\">\n          {loText} – {hiText}\n        </span>\n      </div>\n\n      {bars && (\n        // Decorative: the distribution is not exposed to assistive tech; the thumbs carry the values.\n        <div aria-hidden=\"true\" className=\"flex items-end gap-px\" style={{ height: histogramHeight, paddingInline: THUMB_R }}>\n          {bars.map((count, i) => {\n            const centre = min + ((i + 0.5) / bars.length) * range;\n            const inside = centre >= lo && centre <= hi;\n            return (\n              <span\n                key={i}\n                className={`min-w-0 flex-1 rounded-t-[2px] transition-[background-color] duration-200 motion-reduce:transition-none ${\n                  inside ? \"bg-[var(--drs-accent)]\" : \"bg-zinc-900/[0.12] dark:bg-white/[0.12]\"\n                }`}\n                style={{ height: `${Math.max((Math.max(count, 0) / peak) * 100, count > 0 ? 4 : 0)}%` }}\n              />\n            );\n          })}\n        </div>\n      )}\n\n      <div\n        ref={trackRef}\n        className={`relative h-8 touch-pan-y ${bars ? \"-mt-2\" : bubble === \"off\" ? \"\" : \"mt-7\"} ${disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"}`}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onLostPointerCapture={endDrag}\n      >\n        <div aria-hidden=\"true\" className=\"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-900/10 dark:bg-white/10\">\n          <div\n            className=\"absolute inset-y-0 rounded-full bg-[var(--drs-accent)]\"\n            style={{ left: position(lo), right: `calc(100% - ${position(hi)})` }}\n          />\n        </div>\n\n        {merged && (\n          <div className=\"pointer-events-none absolute top-1/2\" style={{ left: `calc((${position(lo)} + ${position(hi)}) / 2)`, zIndex: 4 }}>\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute bottom-full left-0 mb-4 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm dark:bg-white dark:text-zinc-900\"\n            >\n              {lo === hi ? loText : `${loText} – ${hiText}`}\n            </div>\n          </div>\n        )}\n\n        {thumb(0)}\n        {thumb(1)}\n      </div>\n\n      {showBounds && (\n        <div aria-hidden=\"true\" className=\"-mt-1 flex justify-between font-mono text-[10px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400\">\n          <span>{formatValue(min)}</span>\n          <span>{formatValue(max)}</span>\n        </div>\n      )}\n\n      {name !== undefined && (\n        <>\n          <input type=\"hidden\" name={name} value={lo} />\n          <input type=\"hidden\" name={name} value={hi} />\n        </>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/dual-range-slider"
}
