{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-histogram",
  "title": "Range Histogram",
  "description": "A two-handle range filter drawn over the distribution it filters, so you can see where the results are before you cut them off.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/range-histogram/range-histogram.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type RangeHistogramBin = {\n  /** Inclusive lower edge. */\n  start: number\n  /** Upper edge. Only the last bin includes it. */\n  end: number\n  count: number\n}\n\nexport type RangeHistogramValue = [number, number]\n\nexport interface RangeHistogramProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> {\n  /** Names the control and both handles. */\n  label: string\n  /** The raw values being filtered. The count is exact. */\n  values?: readonly number[]\n  /** Counts the host already has. Used when values is not given. */\n  bins?: readonly RangeHistogramBin[]\n  /** How many bars values are grouped into. Defaults to 24. */\n  binCount?: number\n  /** Defaults to the lowest value or the first bin's start. */\n  min?: number\n  /** Defaults to the highest value or the last bin's end. */\n  max?: number\n  step?: number\n  /** One press of Page Up, Page Down, or a Shift+arrow. Defaults to ten steps. */\n  largeStep?: number\n  value?: RangeHistogramValue\n  defaultValue?: RangeHistogramValue\n  /** Fires on every movement. */\n  onValueChange?: (value: RangeHistogramValue) => void\n  /** Fires when a drag ends, a key is released, or an input is committed. */\n  onValueCommit?: (value: RangeHistogramValue) => void\n  /** Writes a value for the readout and for each handle's aria-valuetext. */\n  formatValue?: (value: number) => string\n  /** The line under the chart. */\n  formatCount?: (count: number, total: number) => string\n  /** Shows a minimum and maximum number field below the chart. */\n  inputs?: boolean\n  disabled?: boolean\n}\n\ntype Thumb = 0 | 1\n\nfunction decimalsOf(step: number) {\n  const text = String(step)\n  const dot = text.indexOf(\".\")\n  return dot === -1 ? 0 : text.length - dot - 1\n}\n\nfunction binValues(\n  values: readonly number[],\n  min: number,\n  max: number,\n  count: number\n): RangeHistogramBin[] {\n  const size = (max - min) / count || 1\n  const bins = Array.from({ length: count }, (_, index) => ({\n    start: min + index * size,\n    end: index === count - 1 ? max : min + (index + 1) * size,\n    count: 0,\n  }))\n\n  for (const value of values) {\n    if (value < min || value > max) continue\n    const index = Math.min(Math.floor((value - min) / size), count - 1)\n    const bin = bins[index]\n    if (bin) bin.count += 1\n  }\n\n  return bins\n}\n\n/** The share of a bin that lies inside [low, high], from 0 to 1. */\nfunction coverage(bin: RangeHistogramBin, low: number, high: number) {\n  const width = bin.end - bin.start\n  if (width <= 0) return bin.start >= low && bin.start <= high ? 1 : 0\n  const overlap = Math.min(bin.end, high) - Math.max(bin.start, low)\n  return Math.min(Math.max(overlap / width, 0), 1)\n}\n\nconst defaultFormat = (value: number) => value.toLocaleString()\nconst defaultCount = (count: number, total: number) =>\n  `${count.toLocaleString()} of ${total.toLocaleString()} results`\n\n/**\n * A two-handle range filter drawn over the distribution it filters, so the\n * person choosing a price or a duration can see where the results actually\n * are before they cut them off.\n */\nexport const RangeHistogram = React.forwardRef<\n  HTMLDivElement,\n  RangeHistogramProps\n>(function RangeHistogram(\n  {\n    label,\n    values,\n    bins: binsProp,\n    binCount = 24,\n    min: minProp,\n    max: maxProp,\n    step = 1,\n    largeStep: largeStepProp,\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    onValueCommit,\n    formatValue = defaultFormat,\n    formatCount = defaultCount,\n    inputs = false,\n    disabled = false,\n    className,\n    ...rootProps\n  },\n  forwardedRef\n) {\n  const id = React.useId()\n  const labelId = `${id}-label`\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const thumbRefs = React.useRef<(HTMLDivElement | null)[]>([])\n  const decimals = decimalsOf(step)\n  const largeStep = largeStepProp ?? step * 10\n\n  const [min, max] = React.useMemo(() => {\n    if (minProp !== undefined && maxProp !== undefined)\n      return [minProp, maxProp]\n    let low = Infinity\n    let high = -Infinity\n    if (values?.length) {\n      for (const value of values) {\n        if (value < low) low = value\n        if (value > high) high = value\n      }\n      low = Math.floor(low / step) * step\n      high = Math.ceil(high / step) * step\n    } else if (binsProp?.length) {\n      low = binsProp[0]?.start ?? 0\n      high = binsProp[binsProp.length - 1]?.end ?? 0\n    } else {\n      low = 0\n      high = 100\n    }\n    return [minProp ?? low, maxProp ?? Math.max(high, low + step)]\n  }, [binsProp, maxProp, minProp, step, values])\n\n  const bins = React.useMemo(\n    () =>\n      values\n        ? binValues(values, min, max, Math.max(1, Math.round(binCount)))\n        : (binsProp ?? []),\n    [binCount, binsProp, max, min, values]\n  )\n\n  const snap = React.useCallback(\n    (raw: number) => {\n      const stepped = min + Math.round((raw - min) / step) * step\n      const clamped = Math.min(Math.max(stepped, min), max)\n      return Number(clamped.toFixed(decimals))\n    },\n    [decimals, max, min, step]\n  )\n\n  const [internal, setInternal] = React.useState<RangeHistogramValue>(\n    () => defaultValue ?? [min, max]\n  )\n  const raw = valueProp ?? internal\n  const low = snap(Math.min(raw[0], raw[1]))\n  const high = snap(Math.max(raw[0], raw[1]))\n  const current = React.useRef<RangeHistogramValue>([low, high])\n  current.current = [low, high]\n\n  const [active, setActive] = React.useState<Thumb | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n  const [drafts, setDrafts] = React.useState<[string | null, string | null]>([\n    null,\n    null,\n  ])\n  const [announced, setAnnounced] = React.useState(\"\")\n\n  const total = React.useMemo(\n    () =>\n      values ? values.length : bins.reduce((sum, bin) => sum + bin.count, 0),\n    [bins, values]\n  )\n\n  const countOf = React.useCallback(\n    (from: number, to: number) => {\n      if (values) {\n        let inside = 0\n        for (const value of values)\n          if (value >= from && value <= to) inside += 1\n        return inside\n      }\n      // Without raw values a partly covered bin can only be estimated, on the\n      // assumption that its items are spread evenly across it.\n      return Math.round(\n        bins.reduce((sum, bin) => sum + bin.count * coverage(bin, from, to), 0)\n      )\n    },\n    [bins, values]\n  )\n\n  const count = React.useMemo(() => countOf(low, high), [countOf, high, low])\n  const tallest = Math.max(1, ...bins.map((bin) => bin.count))\n  const span = max - min || 1\n  const percent = (value: number) => ((value - min) / span) * 100\n\n  const commit = (next: RangeHistogramValue) => {\n    onValueCommit?.(next)\n    setAnnounced(formatCount(countOf(next[0], next[1]), total))\n  }\n\n  const setThumb = (thumb: Thumb, rawValue: number) => {\n    const [from, to] = current.current\n    const value =\n      thumb === 0\n        ? Math.min(snap(rawValue), to)\n        : Math.max(snap(rawValue), from)\n    const next: RangeHistogramValue = thumb === 0 ? [value, to] : [from, value]\n    if (next[0] === from && next[1] === to) return next\n    current.current = next\n    if (valueProp === undefined) setInternal(next)\n    onValueChange?.(next)\n    return next\n  }\n\n  const valueAt = (clientX: number) => {\n    const rect = trackRef.current?.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 + ratio * span\n  }\n\n  const pick = (target: number): Thumb => {\n    const [from, to] = current.current\n    if (from === to) return target < from ? 0 : 1\n    return Math.abs(target - from) <= Math.abs(target - to) ? 0 : 1\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || (event.pointerType === \"mouse\" && event.button !== 0))\n      return\n    event.preventDefault()\n    const target = valueAt(event.clientX)\n    const thumb = pick(target)\n    event.currentTarget.setPointerCapture(event.pointerId)\n    setActive(thumb)\n    setDragging(true)\n    setThumb(thumb, target)\n    thumbRefs.current[thumb]?.focus({ preventScroll: true })\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging || active === null) return\n    setThumb(active, valueAt(event.clientX))\n  }\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging) return\n    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n    setDragging(false)\n    commit(current.current)\n  }\n\n  const handleKeyDown = (thumb: Thumb, event: React.KeyboardEvent) => {\n    if (disabled) return\n    const value = current.current[thumb]\n    const small = event.shiftKey ? largeStep : step\n    const [from, to] = current.current\n    let next: number | null = null\n\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = value - small\n        break\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = value + small\n        break\n      case \"PageDown\":\n        next = value - largeStep\n        break\n      case \"PageUp\":\n        next = value + largeStep\n        break\n      case \"Home\":\n        next = thumb === 0 ? min : from\n        break\n      case \"End\":\n        next = thumb === 0 ? to : max\n        break\n    }\n\n    if (next === null) return\n    event.preventDefault()\n    setThumb(thumb, next)\n  }\n\n  const handleKeyUp = (event: React.KeyboardEvent) => {\n    if (\n      [\n        \"ArrowLeft\",\n        \"ArrowRight\",\n        \"ArrowUp\",\n        \"ArrowDown\",\n        \"PageUp\",\n        \"PageDown\",\n        \"Home\",\n        \"End\",\n      ].includes(event.key)\n    ) {\n      commit(current.current)\n    }\n  }\n\n  const commitDraft = (thumb: Thumb) => {\n    const draft = drafts[thumb]\n    setDrafts((existing) =>\n      thumb === 0 ? [null, existing[1]] : [existing[0], null]\n    )\n    if (draft === null || draft.trim() === \"\") return\n    const parsed = Number(draft)\n    if (Number.isNaN(parsed)) return\n    commit(setThumb(thumb, parsed))\n  }\n\n  const names = [`${label} minimum`, `${label} maximum`] as const\n  const thumbValues = [low, high] as const\n\n  return (\n    <div\n      ref={forwardedRef}\n      role=\"group\"\n      aria-labelledby={labelId}\n      data-slot=\"range-histogram\"\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"text-foreground grid w-full gap-3 data-disabled:opacity-50\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <span id={labelId} className=\"text-sm font-medium\">\n          {label}\n        </span>\n        <span\n          data-slot=\"range-histogram-output\"\n          className=\"font-mono text-sm font-semibold tabular-nums\"\n        >\n          {formatValue(low)} to {formatValue(high)}\n        </span>\n      </div>\n\n      <div\n        data-slot=\"range-histogram-control\"\n        data-dragging={dragging ? \"\" : undefined}\n        className={cn(\n          \"relative touch-none px-2.5 select-none\",\n          disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"\n        )}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n      >\n        <div\n          aria-hidden=\"true\"\n          data-slot=\"range-histogram-bars\"\n          className=\"flex h-16 items-end gap-px\"\n        >\n          {bins.map((bin, index) => {\n            const share = coverage(bin, low, high)\n            const from = Math.min(\n              Math.max((low - bin.start) / (bin.end - bin.start || 1), 0),\n              1\n            )\n            return (\n              <div\n                key={index}\n                data-slot=\"range-histogram-bar\"\n                data-inside={share === 1 ? \"\" : undefined}\n                className=\"bg-muted-foreground/20 relative min-w-0 flex-1 overflow-hidden rounded-t-[3px] transition-[height] duration-200 motion-reduce:transition-none\"\n                style={{\n                  height:\n                    bin.count === 0\n                      ? 0\n                      : `max(3px, ${(bin.count / tallest) * 100}%)`,\n                }}\n              >\n                {share > 0 ? (\n                  <div\n                    className=\"bg-primary absolute inset-y-0\"\n                    style={{ left: `${from * 100}%`, width: `${share * 100}%` }}\n                  />\n                ) : null}\n              </div>\n            )\n          })}\n        </div>\n\n        <div\n          ref={trackRef}\n          data-slot=\"range-histogram-track\"\n          className=\"bg-muted relative mt-2 h-1.5 rounded-full\"\n        >\n          <div\n            className=\"bg-primary absolute inset-y-0 rounded-full\"\n            style={{\n              left: `${percent(low)}%`,\n              width: `${percent(high) - percent(low)}%`,\n            }}\n          />\n          {([0, 1] as const).map((thumb) => {\n            const value = thumbValues[thumb]\n            return (\n              <div\n                key={thumb}\n                ref={(node) => {\n                  thumbRefs.current[thumb] = node\n                }}\n                role=\"slider\"\n                tabIndex={disabled ? -1 : 0}\n                aria-label={names[thumb]}\n                aria-valuemin={thumb === 0 ? min : low}\n                aria-valuemax={thumb === 0 ? high : max}\n                aria-valuenow={value}\n                aria-valuetext={formatValue(value)}\n                aria-orientation=\"horizontal\"\n                aria-disabled={disabled || undefined}\n                data-slot=\"range-histogram-thumb\"\n                data-active={active === thumb ? \"\" : undefined}\n                className={cn(\n                  \"border-primary bg-background absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-md outline-none\",\n                  \"before:absolute before:-inset-3 before:content-['']\",\n                  \"focus-visible:ring-ring/40 transition-[scale,box-shadow] duration-150 focus-visible:ring-4 motion-reduce:transition-none\",\n                  active === thumb ? \"z-20\" : \"z-10\",\n                  dragging && active === thumb && \"scale-110\"\n                )}\n                style={{ left: `${percent(value)}%` }}\n                onFocus={() => setActive(thumb)}\n                onKeyDown={(event) => handleKeyDown(thumb, event)}\n                onKeyUp={handleKeyUp}\n              />\n            )\n          })}\n        </div>\n      </div>\n\n      <p\n        data-slot=\"range-histogram-count\"\n        className=\"text-muted-foreground text-sm tabular-nums\"\n      >\n        {formatCount(count, total)}\n      </p>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n\n      {inputs ? (\n        <div className=\"grid grid-cols-2 gap-3\">\n          {([0, 1] as const).map((thumb) => (\n            <label key={thumb} className=\"grid gap-1.5 text-xs font-medium\">\n              <span className=\"text-muted-foreground\">\n                {thumb === 0 ? \"Minimum\" : \"Maximum\"}\n              </span>\n              <input\n                type=\"number\"\n                inputMode=\"decimal\"\n                disabled={disabled}\n                min={thumb === 0 ? min : low}\n                max={thumb === 0 ? high : max}\n                step={step}\n                aria-label={names[thumb]}\n                data-slot=\"range-histogram-input\"\n                className=\"border-border bg-background focus-visible:ring-ring min-h-9 w-full rounded-md border px-3 text-sm tabular-nums focus-visible:ring-2 focus-visible:outline-none\"\n                value={drafts[thumb] ?? String(thumbValues[thumb])}\n                onChange={(event) => {\n                  const text = event.target.value\n                  setDrafts((existing) =>\n                    thumb === 0 ? [text, existing[1]] : [existing[0], text]\n                  )\n                }}\n                onBlur={() => commitDraft(thumb)}\n                onKeyDown={(event) => {\n                  if (event.key === \"Enter\") commitDraft(thumb)\n                }}\n              />\n            </label>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "form",
    "slider",
    "data",
    "control"
  ],
  "type": "registry:ui"
}