{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "histogram-range",
  "type": "registry:ui",
  "title": "Histogram Range",
  "description": "Price filter whose bars light up exactly between the thumbs as they move.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/histogram-range.tsx",
      "content": "\"use client\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useId, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Range = [number, number];\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Buckets raw values into `bins` equal-width counts across [min, max]. Values past max land in the last bin. */\nexport function binValues(data: number[], min: number, max: number, bins: number) {\n  const counts = new Array<number>(bins).fill(0);\n  const width = (max - min) / bins;\n  for (const v of data) {\n    if (v < min) continue;\n    counts[Math.min(bins - 1, Math.floor((v - min) / width))]++;\n  }\n  return counts;\n}\n\n/** How many values fall inside a range: exact for raw data, prorated across partial bins for counts. */\nexport function countInRange({ data, counts, min, max }: { data?: number[]; counts?: number[]; min: number; max: number }, [lo, hi]: Range) {\n  if (data) return data.filter((v) => v >= lo && (v <= hi || (hi >= max && v > max))).length;\n  if (!counts?.length) return 0;\n  const width = (max - min) / counts.length;\n  return Math.round(\n    counts.reduce((sum, c, i) => {\n      const a = min + i * width;\n      const overlap = Math.max(0, Math.min(hi, a + width) - Math.max(lo, a));\n      return sum + c * (overlap / width);\n    }, 0),\n  );\n}\n\nexport type HistogramRangeProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** Raw values (every listing's price). Binned for you. */\n  data?: number[];\n  /** Or counts you already binned on the server, evenly spread across [min, max]. */\n  counts?: number[];\n  min: number;\n  max: number;\n  step?: number;\n  /** Bars drawn when binning raw data. */\n  bins?: number;\n  value?: Range;\n  defaultValue?: Range;\n  /** Fires continuously while dragging and when an input commits. */\n  onValueChange?: (value: Range) => void;\n  /** Fires on release, key press or input commit: the moment to refetch. */\n  onValueCommitted?: (value: Range) => void;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Formats the inputs and spoken values. Defaults to whole US dollars. */\n  format?: Intl.NumberFormatOptions;\n  locale?: Intl.LocalesArgument;\n  /** The top of the range means \"and above\": the max field says \"and up\" and is spoken as \"or more\". */\n  openEnded?: boolean;\n  /** Accessible names for the two thumbs. */\n  thumbLabels?: [string, string];\n  disabled?: boolean;\n  /** Keeps the space and dims the bars while new counts load. */\n  loading?: boolean;\n};\n\nconst USD: Intl.NumberFormatOptions = { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 };\n\nexport function HistogramRange({\n  data,\n  counts: countsProp,\n  min,\n  max,\n  step = 1,\n  bins = 40,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  label = \"Price range\",\n  description,\n  format = USD,\n  locale,\n  openEnded = false,\n  thumbLabels = [\"Minimum price\", \"Maximum price\"],\n  disabled = false,\n  loading = false,\n  className,\n  ...rest\n}: HistogramRangeProps) {\n  const [value, setValue] = useControllableState<Range>({ value: valueProp, defaultValue: defaultValue ?? [min, max], onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const id = useId();\n  // Thumbs, fill and highlight glide only when a typed value moves them; drags and keys stay 1:1.\n  const [glide, setGlide] = useState(false);\n\n  const counts = useMemo(() => countsProp ?? binValues(data ?? [], min, max, bins), [countsProp, data, min, max, bins]);\n  const peak = Math.max(1, ...counts);\n  const [lo, hi] = value;\n  const pct = (v: number) => ((clamp(v, min, max) - min) / (max - min)) * 100;\n  const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n  const spoken = (v: number) => `${fmt.format(v)}${openEnded && v >= max ? \" or more\" : \"\"}`;\n\n  const commit = (next: Range, typed: boolean) => {\n    setGlide(typed && !reduce);\n    setValue(next);\n    onValueCommitted?.(next);\n  };\n\n  // The lit copy of the bars is clipped to the selected range, so the cut\n  // between in and out sits exactly under each thumb, mid-bar if need be.\n  const clip = `inset(0 ${100 - pct(hi)}% 0 ${pct(lo)}%)`;\n  const glideClass = glide ? \"transition-[clip-path,inset-inline-start,left,width] duration-200 ease-in-out-quart\" : \"\";\n\n  const bars = (lit: boolean) => (\n    <div\n      aria-hidden\n      className={cn(\"absolute inset-0 flex items-end gap-px\", lit && glideClass)}\n      style={lit ? { clipPath: clip } : undefined}\n    >\n      {counts.map((c, i) => (\n        <span\n          key={i}\n          style={{ height: `${c ? Math.max(4, (c / peak) * 100) : 0}%` }}\n          className={cn(\n            \"min-w-0 flex-1 origin-bottom rounded-t-[2px]\",\n            \"transition-[height] duration-300 ease-out-quart motion-reduce:transition-none\",\n            lit ? \"bg-fg\" : \"bg-fg-4\",\n          )}\n        />\n      ))}\n    </div>\n  );\n\n  // Typed values clamp against the other end, keeping one step between them like the thumbs do.\n  const settleTyped = (index: 0 | 1, n: number | null) => {\n    if (n === null) return commit(value, true);\n    const next: Range = index === 0 ? [clamp(n, min, hi - step), hi] : [lo, clamp(n, lo + step, max)];\n    commit(next, true);\n  };\n\n  const field = (index: 0 | 1) => {\n    const v = value[index];\n    const name = index === 0 ? \"Minimum\" : \"Maximum\";\n    const upId = `${id}-up`;\n    return (\n      <NumberField.Root\n        value={v}\n        min={index === 0 ? min : lo + step}\n        max={index === 0 ? hi - step : max}\n        step={step}\n        largeStep={step * 10}\n        format={format}\n        locale={locale}\n        disabled={disabled}\n        onValueChange={(n, details) => {\n          // Typing waits for Enter or blur; arrow keys and steppers apply at once.\n          if (details.reason.startsWith(\"input\") || n === null) return;\n          const next: Range = index === 0 ? [n, hi] : [lo, n];\n          setGlide(false);\n          setValue(next);\n        }}\n        onValueCommitted={(n) => settleTyped(index, n)}\n        className=\"min-w-0 flex-1\"\n      >\n        <label htmlFor={`${id}-${index}`} className=\"mb-1 block text-[11.5px] text-fg-3\">\n          {name}\n        </label>\n        <NumberField.Group\n          className={cn(\n            \"flex h-8 items-center rounded-lg border border-line-2 bg-raised px-2.5 transition-[border-color,box-shadow] duration-150\",\n            \"focus-within:border-fg-4 focus-within:ring-2 focus-within:ring-fg/10\",\n            \"data-[disabled]:opacity-50\",\n          )}\n        >\n          <NumberField.Input\n            id={`${id}-${index}`}\n            aria-describedby={index === 1 && openEnded && v >= max ? upId : undefined}\n            onKeyDown={(e) => {\n              // Enter commits like blur does (format, clamp, move the thumb) and keeps focus here.\n              if (e.key === \"Enter\") {\n                const el = e.currentTarget;\n                el.blur();\n                el.focus();\n              }\n            }}\n            className=\"tabular w-full min-w-0 bg-transparent text-base text-fg outline-none sm:text-[13px]\"\n          />\n          {index === 1 && openEnded && v >= max && (\n            <span id={upId} className=\"shrink-0 text-[12px] text-fg-3\">\n              and up\n            </span>\n          )}\n        </NumberField.Group>\n      </NumberField.Root>\n    );\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={`${id}-label`}\n      aria-busy={loading || undefined}\n      data-disabled={disabled || undefined}\n      className={cn(\"flex w-full flex-col\", disabled && \"opacity-60\", className)}\n      {...rest}\n    >\n      <div className=\"mb-3\">\n        <p id={`${id}-label`} className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">\n          {label}\n        </p>\n        {description && <p className=\"mt-0.5 text-[12px] text-fg-3\">{description}</p>}\n      </div>\n\n      {/* Bars and track share one box, so bar edges and thumb positions agree. */}\n      <div className={cn(\"relative h-16 transition-opacity duration-200\", loading && \"animate-pulse-soft opacity-60\")}>\n        {bars(false)}\n        {bars(true)}\n      </div>\n\n      <Slider.Root\n        value={value}\n        min={min}\n        max={max}\n        step={step}\n        largeStep={step * 10}\n        minStepsBetweenValues={1}\n        thumbCollisionBehavior=\"none\"\n        disabled={disabled}\n        format={format}\n        locale={locale}\n        onValueChange={(v) => {\n          setGlide(false);\n          setValue(v as Range);\n        }}\n        onValueCommitted={(v) => {\n          onValueCommitted?.(v as Range);\n        }}\n        className=\"group/range\"\n      >\n        <Slider.Control\n          // Pulled up so the track is the baseline the bars stand on.\n          className={cn(\"relative -mt-[11px] flex h-6 touch-none items-center select-none [@media(pointer:coarse)]:-mt-[21px] [@media(pointer:coarse)]:h-11\", !disabled && \"cursor-pointer\")}\n        >\n          <Slider.Track className=\"relative h-0.5 w-full rounded-full bg-line-2\">\n            <Slider.Indicator className={cn(\"rounded-full bg-fg\", glideClass)} />\n            {[0, 1].map((i) => (\n              <Slider.Thumb\n                key={i}\n                index={i}\n                aria-label={thumbLabels[i]}\n                getAriaValueText={(_, v) => spoken(v)}\n                className={cn(\n                  // A foreground puck with a surface-colored rim, so it reads in both themes and cuts the track cleanly.\n                  \"size-[18px] rounded-full border-[3px] border-raised bg-fg shadow-[var(--shadow)]\",\n                  \"transition-[scale] duration-150 ease-out hover:scale-[1.08]\",\n                  // The thumb in hand grows a touch and firms its edge.\n                  \"data-[dragging]:scale-[1.15] motion-reduce:data-[dragging]:scale-100 motion-reduce:hover:scale-100\",\n                  \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3\",\n                  \"[@media(pointer:coarse)]:before:absolute [@media(pointer:coarse)]:before:-inset-3.5\",\n                  glideClass,\n                )}\n              />\n            ))}\n          </Slider.Track>\n        </Slider.Control>\n      </Slider.Root>\n\n      <div className=\"mt-3 flex items-end gap-2\">\n        {field(0)}\n        <span aria-hidden className=\"pb-2 text-[13px] text-fg-4\">\n          –\n        </span>\n        {field(1)}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/histogram-range"
}
