{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-range-slider",
  "type": "registry:ui",
  "title": "Range Slider",
  "description": "Two thumbs on one track for a span such as a note range, on Base UI's Slider. Controlled and told which thumb moved; labels point outward from their thumbs so they cannot collide, and the same formatter feeds aria-valuetext. Thumb hit areas are at least the sm control height (24px, 44 on touch). With keyboard the 24px piano strip is the track: one equal column per note, bar thumbs drawn on it, labels inline beside them, the range tinted with the value fill, pressing a key moves the nearer thumb, Shift+arrow jumps an octave.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/range-slider.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/range-slider\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * RangeSlider — two thumbs on one track, for a span such as a melody's note\n * range: `[low, high]`.\n *\n * Built on Base UI's Slider, which owns everything a range control has to get\n * right: pointer capture, pressing the track to move the nearer thumb, thumbs\n * that push each other instead of crossing, the keyboard (arrows by `step`,\n * Shift+arrow or Page Up/Down by `largeStep`, Home and End to the rail or the\n * other thumb) and a native `<input type=\"range\">` inside each thumb, so a\n * screen reader can adjust it on every platform, touch included. What this\n * file adds is the look, the value labels and the piano strip.\n *\n * ## Controlled, and told which thumb moved\n *\n * `value` is the pair and the consumer owns it. Every change goes out through\n * `onValueChange(value, activeThumb)`; `onValueCommitted` fires once when a\n * drag ends or a key is pressed, which is where an editor pushes one undo step\n * for a whole drag. A crossed pair is drawn sorted and a value off the track\n * is drawn on the rail (see `normalizeRangeValue`), so widen `min`/`max` to\n * show a value that lies outside them.\n *\n * ## Labels that cannot collide\n *\n * Each thumb's value, spelled by `formatValue`, points outward from its\n * thumb: the low label ends at the low thumb and the high label starts at the\n * high thumb. Since low never passes high, the two labels only touch when the\n * thumbs themselves do. Near a rail a label slides back inside the control\n * rather than hanging off it (an auto margin gives way when there is no\n * room). The same `formatValue` feeds `aria-valuetext`, so what a screen\n * reader says and what the label shows agree. Without `keyboard` the labels\n * sit in a row above the track; with it they sit inline on the strip, beside\n * their thumbs.\n *\n * ## The piano strip\n *\n * With `keyboard`, the values are MIDI notes and a keyboard strip is the\n * track itself: 24px tall (the sm control height, `--uikit-control-h-sm`),\n * one equal column per note so each key sits exactly under the thumb that\n * names it (`rangeKeyStrip` explains the layout), with the thumbs drawn on it\n * as bars. The selected keys are tinted. Pressing a key moves the nearer\n * thumb to it. Arrows then step by a semitone and Shift+arrow by an octave,\n * and the default labels are note names (\"E4\").\n *\n * ## Hit areas\n *\n * Every thumb's hit area is at least the sm control height square (24px, and\n * 44px where a touch face raises `--uikit-control-h-sm`), whatever its drawn\n * size, and so is the track row.\n *\n * Styling comes from the kit's CSS custom properties (`lib/control-theme`):\n * the range fills with the value colour (`--uikit-value`), and `color`\n * overrides it for one instance. SSR-safe: nothing here reads the DOM during\n * render, and Base UI measures only in effects.\n */\n\nimport { useMemo, useState } from \"react\"\nimport type { CSSProperties, FocusEvent } from \"react\"\nimport { Slider } from \"@base-ui/react/slider\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  CONSOLE_VALUE,\n  consoleHeight,\n} from \"maruaudio/ui/shared/console-styles\"\nimport {\n  RANGE_KEY_BLACK_HEIGHT,\n  RANGE_SLIDER_NOTE_LARGE_STEP,\n  RANGE_SLIDER_THUMB_LABELS,\n  defaultRangeFormat,\n  normalizeRangeValue,\n  rangeFraction,\n  rangeKeyStrip,\n  rangeLitKeys,\n  type RangeKeyStrip,\n  type RangeSliderValue,\n} from \"maruaudio/ui/shared/range-slider-math\"\n\nexport interface RangeSliderProps {\n  /** The pair, low first. */\n  readonly value: RangeSliderValue\n  /**\n   * Every change, as the whole pair, with the index of the thumb that moved:\n   * 0 for the low thumb, 1 for the high one.\n   */\n  readonly onValueChange?: (value: [number, number], activeThumb: number) => void\n  /** Once per gesture: when a drag or a track press ends, or per key press. */\n  readonly onValueCommitted?: (value: [number, number]) => void\n  /** Defaults to 0. */\n  readonly min?: number\n  /** Defaults to 100. */\n  readonly max?: number\n  /** Defaults to 1. */\n  readonly step?: number\n  /**\n   * The Shift+arrow and Page Up/Down step. Defaults to an octave (12) with\n   * `keyboard`, and to 10 otherwise.\n   */\n  readonly largeStep?: number\n  /** The fewest steps the thumbs may be apart. Defaults to 0: they may meet. */\n  readonly minStepsBetweenValues?: number\n  /**\n   * Spells a value for the thumb labels and `aria-valuetext`. Defaults to\n   * note names with `keyboard`, and to the plain number otherwise.\n   */\n  readonly formatValue?: (value: number) => string\n  /** Accessible names for the low and high thumbs. */\n  readonly thumbLabels?: readonly [string, string]\n  /** The values are MIDI notes: draw a piano strip under the track. */\n  readonly keyboard?: boolean\n  /** Draw each thumb's value above it. Defaults to true. */\n  readonly showLabels?: boolean\n  readonly disabled?: boolean\n  /** Override the range colour (the value fill) for this instance. */\n  readonly color?: string\n  /** Defaults to filling the container. */\n  readonly width?: number | string\n  readonly className?: string\n  readonly style?: CSSProperties\n  /** The group's accessible name. Defaults to \"Range\". */\n  readonly \"aria-label\"?: string\n}\n\n/** Drawn thumb diameter in pixels (the hit area is larger: {@link HIT}). */\nconst THUMB = 14\n/** Track thickness in pixels. */\nconst TRACK = 4\n/** A thumb's hit area and the track row's height: the sm control height. */\nconst HIT = consoleHeight(\"sm\")\n/** The piano strip's height: the sm control height too. */\nconst STRIP = consoleHeight(\"sm\")\n/** Width in pixels of a thumb drawn as a bar on the strip. */\nconst BAR = 6\n/** Distance in pixels from a bar thumb's centre to its inline label. */\nconst BAR_LABEL_GAP = BAR / 2 + 3\n/** Label row height in pixels. */\nconst LABEL_ROW = 16\n\n/**\n * The black keys' face. Not a token, for the reason `piano-keyboard` gives:\n * black keys are black in every skin, and a skin that turned them pale would\n * be a broken piano rather than a re-skinned one.\n */\nconst BLACK_KEY_FILL = \"rgba(8, 10, 14, 0.92)\"\n\n/** Where a keyboard focus ring should show: after keys, not after a press. */\nfunction matchesFocusVisible(element: Element): boolean {\n  try {\n    return element.matches(\":focus-visible\")\n  } catch {\n    return true\n  }\n}\n\ninterface ThumbFocus {\n  index: number\n  /** Keyboard focus, which gets the ring; a pointer press only lights the label. */\n  visible: boolean\n}\n\nexport function RangeSlider({\n  value,\n  onValueChange,\n  onValueCommitted,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep,\n  minStepsBetweenValues = 0,\n  formatValue,\n  thumbLabels = RANGE_SLIDER_THUMB_LABELS,\n  keyboard = false,\n  showLabels = true,\n  disabled = false,\n  color,\n  width = \"100%\",\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: RangeSliderProps) {\n  const lit = color ?? CONSOLE_VALUE\n  const format = formatValue ?? defaultRangeFormat(keyboard)\n  const [low, high] = normalizeRangeValue(value, min, max)\n  const pair = useMemo(() => [low, high], [low, high])\n  const strip = useMemo(\n    () => (keyboard ? rangeKeyStrip(min, max) : null),\n    [keyboard, min, max],\n  )\n  const [focus, setFocus] = useState<ThumbFocus | null>(null)\n\n  const lowT = rangeFraction(low, min, max)\n  const highT = rangeFraction(high, min, max)\n\n  // Half a thumb of padding at each end, so a thumb on a rail overhangs into\n  // the padding instead of off the control. With a strip, the end keys are\n  // centred on the rails and overhang by half a key; the padding widens to\n  // hold them. Percentages there resolve against the parent's width, which is\n  // the control's own width when it fills its container (the default).\n  const overhangStart = strip && strip.width > 0 ? strip.overhangStart : 0\n  const overhangEnd = strip && strip.width > 0 ? strip.overhangEnd : 0\n  const scale = 1 + overhangStart + overhangEnd\n  const inset = (overhang: number) =>\n    overhang > 0\n      ? `max(${THUMB / 2}px, ${((overhang / scale) * 100).toFixed(4)}%)`\n      : THUMB / 2\n\n  const keyStrip = strip && strip.width > 0 ? strip : null\n  const bars = keyStrip !== null\n\n  const thumb = (index: 0 | 1) => {\n    const focused = focus?.index === index\n    const ring =\n      focused && focus?.visible\n        ? `0 0 0 3px ${token.focus}`\n        : \"0 1px 3px rgba(0, 0, 0, 0.45)\"\n    return (\n      <Slider.Thumb\n        key={index}\n        index={index}\n        aria-label={thumbLabels[index]}\n        getAriaValueText={(_formatted, thumbValue) => format(thumbValue)}\n        data-range-thumb={index === 0 ? \"low\" : \"high\"}\n        onFocus={(event: FocusEvent<HTMLInputElement>) =>\n          setFocus({ index, visible: matchesFocusVisible(event.currentTarget) })\n        }\n        onBlur={() => setFocus((current) => (current?.index === index ? null : current))}\n        onKeyDown={() =>\n          setFocus((current) =>\n            current?.index === index && !current.visible ? { index, visible: true } : current,\n          )\n        }\n        style={{\n          // The hit area: transparent, at least the sm control height square.\n          boxSizing: \"border-box\",\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          width: HIT,\n          height: HIT,\n          cursor: disabled ? \"not-allowed\" : \"grab\",\n          outline: \"none\",\n        }}\n      >\n        <span\n          aria-hidden\n          data-range-thumb-mark=\"\"\n          style={\n            bars\n              ? {\n                  boxSizing: \"border-box\",\n                  width: BAR,\n                  height: \"100%\",\n                  borderRadius: 3,\n                  background: lit,\n                  border: `1px solid ${token.surface}`,\n                  boxShadow: ring,\n                  pointerEvents: \"none\",\n                }\n              : {\n                  boxSizing: \"border-box\",\n                  width: THUMB,\n                  height: THUMB,\n                  borderRadius: \"50%\",\n                  background: token.cap,\n                  border: `2px solid ${lit}`,\n                  boxShadow: ring,\n                  pointerEvents: \"none\",\n                }\n          }\n        />\n      </Slider.Thumb>\n    )\n  }\n\n  const label = (index: 0 | 1) => {\n    const isLow = index === 0\n    const t = isLow ? lowT : highT\n    // Above the track, the box runs from the thumb's outer edge to the outer\n    // edge of a thumb parked on the rail, and the label keeps to the thumb's\n    // end of it while it fits. Inline on the strip, the box ends beside the\n    // bar, so the label sits next to its thumb rather than over it.\n    const box: CSSProperties = bars\n      ? isLow\n        ? { left: 0, width: `max(0px, calc(${t * 100}% - ${BAR_LABEL_GAP}px))` }\n        : {\n            left: `calc(${t * 100}% + ${BAR_LABEL_GAP}px)`,\n            right: 0,\n            flexDirection: \"row-reverse\",\n          }\n      : isLow\n        ? { left: -THUMB / 2, width: `calc(${t * 100}% + ${THUMB}px)` }\n        : {\n            left: `calc(${t * 100}% - ${THUMB / 2}px)`,\n            right: -THUMB / 2,\n            flexDirection: \"row-reverse\",\n          }\n    return (\n      <div\n        key={index}\n        style={{\n          position: \"absolute\",\n          top: 0,\n          bottom: 0,\n          display: \"flex\",\n          alignItems: \"center\",\n          ...box,\n        }}\n      >\n        <span\n          data-range-label={isLow ? \"low\" : \"high\"}\n          style={{\n            ...(isLow ? { marginLeft: \"auto\" } : { marginRight: \"auto\" }),\n            fontFamily: token.fontMono,\n            fontSize: 11,\n            lineHeight: `${LABEL_ROW}px`,\n            whiteSpace: \"nowrap\",\n            color: token.text,\n            ...(bars\n              ? {\n                  padding: \"0 3px\",\n                  borderRadius: 3,\n                  background: token.surface,\n                  boxShadow: focus?.index === index ? `inset 0 -2px 0 ${lit}` : undefined,\n                }\n              : { textDecoration: focus?.index === index ? \"underline\" : undefined }),\n          }}\n        >\n          {format(isLow ? low : high)}\n        </span>\n      </div>\n    )\n  }\n\n  return (\n    <Slider.Root\n      value={pair}\n      onValueChange={(next, details) => {\n        const [a, b] = normalizeRangeValue(next, min, max)\n        onValueChange?.([a, b], details.activeThumbIndex)\n      }}\n      onValueCommitted={(next) => {\n        const [a, b] = normalizeRangeValue(next, min, max)\n        onValueCommitted?.([a, b])\n      }}\n      min={min}\n      max={max}\n      step={step}\n      largeStep={largeStep ?? (keyboard ? RANGE_SLIDER_NOTE_LARGE_STEP : 10)}\n      minStepsBetweenValues={minStepsBetweenValues}\n      disabled={disabled}\n      aria-label={ariaLabel ?? \"Range\"}\n      data-range-slider=\"\"\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      style={{\n        boxSizing: \"border-box\",\n        width,\n        paddingLeft: inset(overhangStart),\n        paddingRight: inset(overhangEnd),\n        fontFamily: token.font,\n        opacity: disabled ? 0.5 : 1,\n        ...style,\n      }}\n    >\n      {showLabels && !bars ? (\n        <div aria-hidden style={{ position: \"relative\", height: LABEL_ROW, marginBottom: 2 }}>\n          {label(0)}\n          {label(1)}\n        </div>\n      ) : null}\n\n      <Slider.Control\n        data-range-control={bars ? \"keys\" : \"track\"}\n        style={{\n          display: \"grid\",\n          touchAction: \"none\",\n          userSelect: \"none\",\n          cursor: disabled ? \"not-allowed\" : \"pointer\",\n        }}\n      >\n        {keyStrip ? (\n          <RangeKeys\n            strip={keyStrip}\n            low={low}\n            high={high}\n            color={lit}\n            overhangStart={overhangStart}\n            overhangEnd={overhangEnd}\n          />\n        ) : null}\n        {showLabels && bars ? (\n          <div aria-hidden style={{ gridArea: \"1 / 1\", position: \"relative\", pointerEvents: \"none\" }}>\n            {label(0)}\n            {label(1)}\n          </div>\n        ) : null}\n        <div\n          style={{\n            gridArea: \"1 / 1\",\n            display: \"flex\",\n            alignItems: \"center\",\n            height: bars ? STRIP : HIT,\n          }}\n        >\n          <Slider.Track\n            style={{\n              width: \"100%\",\n              height: bars ? \"100%\" : TRACK,\n              borderRadius: 999,\n              background: bars ? \"transparent\" : token.track,\n            }}\n          >\n            {bars ? null : <Slider.Indicator style={{ background: lit, borderRadius: 999 }} />}\n            {thumb(0)}\n            {thumb(1)}\n          </Slider.Track>\n        </div>\n      </Slider.Control>\n    </Slider.Root>\n  )\n}\n\n/**\n * The piano strip: equal columns in an SVG stretched to the control's width,\n * so the drawing is in notes and the browser scales it. Decorative, because\n * the thumbs already say which notes are selected.\n */\nfunction RangeKeys({\n  strip,\n  low,\n  high,\n  color,\n  overhangStart,\n  overhangEnd,\n}: {\n  strip: RangeKeyStrip\n  low: number\n  high: number\n  color: string\n  overhangStart: number\n  overhangEnd: number\n}) {\n  const lit = rangeLitKeys(strip, [low, high])\n  return (\n    <div\n      aria-hidden\n      data-range-keys=\"\"\n      style={{\n        gridArea: \"1 / 1\",\n        height: STRIP,\n        // Negative margins resolve against the track's width, so the strip\n        // reaches half a key past each rail and every key centres on its value.\n        marginLeft: `${-overhangStart * 100}%`,\n        marginRight: `${-overhangEnd * 100}%`,\n      }}\n    >\n      <svg\n        viewBox={`0 0 ${strip.width} 1`}\n        preserveAspectRatio=\"none\"\n        width=\"100%\"\n        height=\"100%\"\n        style={{ display: \"block\", overflow: \"visible\" }}\n      >\n        <rect x={0} y={0} width={strip.width} height={1} fill={token.text} fillOpacity={0.26} />\n        {strip.keys.map((key) =>\n          key.black ? (\n            <rect\n              key={key.note}\n              data-key-note={key.note}\n              data-key-black=\"\"\n              x={key.x}\n              y={0}\n              width={1}\n              height={RANGE_KEY_BLACK_HEIGHT}\n              fill={BLACK_KEY_FILL}\n            />\n          ) : (\n            <rect\n              key={key.note}\n              data-key-note={key.note}\n              x={key.x}\n              y={0}\n              width={1}\n              height={1}\n              fill=\"transparent\"\n            />\n          ),\n        )}\n        {strip.splitLines.map((x) => (\n          <line\n            key={`s${x}`}\n            x1={x}\n            x2={x}\n            y1={RANGE_KEY_BLACK_HEIGHT}\n            y2={1}\n            stroke={BLACK_KEY_FILL}\n            strokeOpacity={0.6}\n            strokeWidth={1}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ))}\n        {strip.edgeLines.map((x) => (\n          <line\n            key={`e${x}`}\n            x1={x}\n            x2={x}\n            y1={0}\n            y2={1}\n            stroke={BLACK_KEY_FILL}\n            strokeOpacity={0.6}\n            strokeWidth={1}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ))}\n        {/* A small mark at the front of every C, the landmark octaves are\n            read by. */}\n        {strip.keys\n          .filter((key) => key.octaveStart)\n          .map((key) => (\n            <rect\n              key={`c${key.note}`}\n              x={key.x + 0.3}\n              y={0.8}\n              width={0.4}\n              height={0.1}\n              fill={token.text}\n              fillOpacity={0.7}\n            />\n          ))}\n        {lit ? (\n          <rect\n            data-range-keys-lit=\"\"\n            x={lit.from - strip.low}\n            y={0}\n            width={lit.to - lit.from + 1}\n            height={1}\n            fill={color}\n            fillOpacity={0.38}\n          />\n        ) : null}\n      </svg>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/range-slider.tsx"
    }
  ]
}
