{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-fader-headless",
  "type": "registry:ui",
  "title": "Fader Headless",
  "description": "The unstyled track primitive a fader is built on: absolute pointer positioning, the Shift fine pass anchored where Shift went down, wheel and keyboard stepping, and the full role=\"slider\" contract. Renders no appearance — a render prop hands your face the drag state.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-cc-binding-editor.json",
    "https://pluginui.com/r/core-cc-control-chrome.json"
  ],
  "files": [
    {
      "path": "components/ui/fader-headless.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/fader-headless\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 * FaderHeadless — the unstyled track primitive a fader is built on.\n *\n * The third of the kit's headless layers, and it exists for the same reason as\n * the other two: the *behaviour* of a fader is subtle and worth writing once,\n * while its appearance is worth rewriting per panel.\n *\n * What is subtle about it:\n *\n * - Positioning is absolute, not incremental. Pressing anywhere on the track\n *   jumps the cap there and the same gesture carries straight on into a drag,\n *   the way a real mixer behaves.\n * - Shift switches to *relative* tracking at a fifth speed, anchored where\n *   Shift went down — otherwise holding it would yank the cap back under the\n *   cursor, which is exactly what you don't want mid-move.\n * - The usable travel is inset by half a cap at each end, because a cap is\n *   positioned by its edge. Get that wrong and the extremes sit half a cap\n *   outside the groove and the cap stops landing under the pointer. The\n *   primitive cannot know how long the face's cap is, so `capInset` is a prop\n *   — a capless face passes 0 and gets the full track.\n *\n * Like `KnobHeadless`, this owns no value state: it reports where the pointer\n * put the value and the host decides what to do with it. Labels, readouts,\n * double-click-to-default and MIDI CC live above it.\n */\n\nimport { forwardRef, useCallback, useEffect, useRef } from \"react\"\nimport type {\n  ComponentProps,\n  CSSProperties,\n  ReactElement,\n  ReactNode,\n} from \"react\"\nimport { sanitize } from \"maruaudio/ui/shared/math\"\nimport {\n  calculateWheelValue,\n  type Map01,\n  mapFrom01Linear,\n  mapTo01Linear,\n  positionToValue01,\n  resolveKnobWheelSensitivity,\n  snapToStep,\n} from \"maruaudio/ui/shared/knob-core\"\nimport { useKeyboardStep } from \"maruaudio/ui/shared/use-control\"\nimport { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\nimport {\n  useWheel,\n  wheelDeltaForAxis,\n} from \"maruaudio/ui/shared/use-wheel\"\nimport type { InteractionEvent } from \"maruaudio/ui/shared/use-first-interaction\"\nimport type {\n  CcParamRange,\n  ControlMidiProps,\n} from \"maruaudio/ui/shared/cc-dnd\"\nimport {\n  CcControlChromeTarget,\n  type CcControlChrome,\n} from \"@/components/ui/cc-control-chrome\"\nimport type { CcControlPresentation } from \"@/components/ui/cc-binding-editor\"\n\n/** What a face is handed on every render. */\nexport interface FaderRenderState {\n  /** True while the pointer is down on the track. */\n  dragging: boolean\n  /** The current value, normalised to [0..1] for drawing. */\n  value01: number\n}\n\ntype NativeDivProps = Omit<\n  ComponentProps<\"div\">,\n  | \"role\"\n  | \"aria-valuemin\"\n  | \"aria-valuemax\"\n  | \"aria-valuenow\"\n  | \"aria-valuetext\"\n  | \"aria-orientation\"\n  | \"tabIndex\"\n  | \"children\"\n>\n\nexport interface FaderHeadlessProps extends NativeDivProps {\n  valueRaw: number\n  valueMin: number\n  valueMax: number\n  /** Quantisation grid. 0 or undefined means continuous. */\n  step?: number\n  mapTo01?: Map01\n  mapFrom01?: Map01\n  orientation?: \"vertical\" | \"horizontal\"\n  /**\n   * Length of the face's cap along the travel. Half of it is unusable at each\n   * end; a face without a cap passes 0.\n   */\n  capInset?: number\n  /** Normalised units per wheel notch. 0 disables the wheel. */\n  wheelSensitivity?: number\n  /** How much slower the Shift fine pass runs. */\n  fineFactor?: number\n  /** Formats the value for `aria-valuetext`. */\n  valueRawDisplayFn: (valueRaw: number) => string\n  onValueRawChange: (valueRaw: number) => void\n  /** Fires once when a pointer or wheel gesture begins. */\n  onGestureStart?: () => void\n  /** Fires once a gesture ends — good for undo coalescing. */\n  onGestureEnd?: () => void\n  /** Fires on the first gesture of any kind — for resuming an AudioContext. */\n  onInteraction?: (event: InteractionEvent) => void\n  disabled?: boolean\n  children: (state: FaderRenderState) => ReactNode\n  /** Stable parameter identity; omitted keeps this a gesture-only primitive. */\n  path?: string\n  /** Explicit MIDI wiring wins over the ambient path resolver. */\n  midi?: ControlMidiProps\n  ccLabel?: string\n  /** Parameter units for mapping; do not substitute normalised gesture bounds. */\n  ccRange?: CcParamRange\n  ccPresentation?: CcControlPresentation\n  ccFrameAs?: \"div\" | \"span\" | \"label\"\n  ccFrameClassName?: string\n  ccFrameStyle?: CSSProperties | ((chrome: CcControlChrome) => CSSProperties)\n  renderFrame?: (state: {\n    control: ReactElement\n    chrome: CcControlChrome\n  }) => ReactNode\n  renderCcBadge?:\n    false | ((badge: ReactNode, chrome: CcControlChrome) => ReactNode)\n}\n\nexport const FaderHeadless = forwardRef<HTMLDivElement, FaderHeadlessProps>(\n  function FaderHeadless(\n    {\n      valueRaw,\n      valueMin,\n      valueMax,\n      step = 0,\n      mapTo01 = mapTo01Linear,\n      mapFrom01 = mapFrom01Linear,\n      orientation = \"vertical\",\n      capInset = 0,\n      wheelSensitivity,\n      fineFactor = 5,\n      valueRawDisplayFn,\n      onValueRawChange,\n      onGestureStart,\n      onGestureEnd,\n      onInteraction,\n      disabled = false,\n      children,\n      path,\n      midi,\n      ccLabel,\n      ccRange,\n      ccPresentation,\n      ccFrameAs,\n      ccFrameClassName,\n      ccFrameStyle,\n      renderFrame,\n      renderCcBadge,\n      ...rest\n    },\n    forwardedRef\n  ) {\n    const vertical = orientation === \"vertical\"\n    const trackRef = useRef<HTMLDivElement | null>(null)\n    // The pointer and wheel handlers are bound outside React's synthetic\n    // system, so they read the live value rather than a stale closure.\n    const latest = useRef(valueRaw)\n    latest.current = valueRaw\n    const wheelValueRaw = useRef(valueRaw)\n    const wheelActive = useRef(false)\n\n    const effectiveWheelSensitivity = resolveKnobWheelSensitivity(\n      wheelSensitivity,\n      step > 0 ? step : undefined,\n      valueMin,\n      valueMax\n    )\n\n    const valueAtPointer = useCallback(\n      (clientX: number, clientY: number): number | null => {\n        const rect = trackRef.current?.getBoundingClientRect()\n        if (!rect) return null\n        const travel = (vertical ? rect.height : rect.width) - capInset\n        const t = vertical\n          ? positionToValue01(clientY, rect.top + capInset / 2, travel, true)\n          : positionToValue01(clientX, rect.left + capInset / 2, travel, false)\n        return mapFrom01(t, valueMin, valueMax)\n      },\n      [capInset, vertical, valueMin, valueMax, mapFrom01]\n    )\n\n    const fineAnchor = useRef<{ value: number; x: number; y: number } | null>(\n      null\n    )\n\n    const drag = useSurfacePointer(\n      (state) => {\n        if (state.first) {\n          onInteraction?.(state.event)\n          onGestureStart?.()\n        }\n\n        if (state.fine) {\n          const anchor =\n            fineAnchor.current ??\n            (fineAnchor.current = {\n              value: latest.current,\n              x: state.clientX,\n              y: state.clientY,\n            })\n          const rect = trackRef.current?.getBoundingClientRect()\n          const axisLength = (vertical ? rect?.height : rect?.width) ?? 0\n          const moved = vertical\n            ? anchor.y - state.clientY\n            : state.clientX - anchor.x\n          const span = axisLength * fineFactor\n          if (span > 0) {\n            onValueRawChange(\n              mapFrom01(\n                mapTo01(anchor.value, valueMin, valueMax) + moved / span,\n                valueMin,\n                valueMax\n              )\n            )\n          }\n          return\n        }\n\n        fineAnchor.current = null\n        const next = valueAtPointer(state.clientX, state.clientY)\n        if (next !== null) onValueRawChange(next)\n      },\n      {\n        disabled,\n        onEnd: () => {\n          fineAnchor.current = null\n          onGestureEnd?.()\n        },\n      }\n    )\n\n    const { wheeling } = useWheel(\n      trackRef,\n      (state) => {\n        onInteraction?.(state.event)\n        if (!wheelActive.current) {\n          wheelActive.current = true\n          wheelValueRaw.current = latest.current\n          onGestureStart?.()\n        }\n\n        const next = calculateWheelValue({\n          valueRaw: wheelValueRaw.current,\n          valueMin,\n          valueMax,\n          wheelDelta: wheelDeltaForAxis(state, vertical ? \"y\" : \"x\"),\n          wheelSensitivity: effectiveWheelSensitivity,\n          mapTo01,\n          mapFrom01,\n          // A trackpad may send many fractions of a notch before a detent.\n        })\n        wheelValueRaw.current = next\n        onValueRawChange(snapToStep(next, valueMin, valueMax, step))\n      },\n      { disabled: disabled || !effectiveWheelSensitivity }\n    )\n\n    const wheelingRef = useRef(wheeling)\n    useEffect(() => {\n      if (wheelingRef.current === wheeling) return\n      wheelingRef.current = wheeling\n      if (!wheeling && wheelActive.current) {\n        wheelActive.current = false\n        wheelValueRaw.current = latest.current\n        onGestureEnd?.()\n      }\n    }, [onGestureEnd, wheeling])\n\n    const keyboardActive = useRef(false)\n    const endKeyboardGesture = () => {\n      if (!keyboardActive.current) return\n      keyboardActive.current = false\n      onGestureEnd?.()\n    }\n    const handleKeyDown = useKeyboardStep({\n      value: valueRaw,\n      disabled,\n      onCommit: onValueRawChange,\n      min: valueMin,\n      max: valueMax,\n      step,\n    })\n\n    const value01 =\n      valueMax === valueMin\n        ? 0\n        : mapTo01(\n            sanitize(valueRaw, valueMin, valueMax, step),\n            valueMin,\n            valueMax\n          )\n\n    const control = (\n      <div\n        ref={(node) => {\n          trackRef.current = node\n          if (typeof forwardedRef === \"function\") forwardedRef(node)\n          else if (forwardedRef) forwardedRef.current = node\n        }}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-valuemin={valueMin}\n        aria-valuemax={valueMax}\n        aria-valuenow={valueRaw}\n        aria-valuetext={valueRawDisplayFn(valueRaw)}\n        aria-orientation={orientation}\n        aria-disabled={disabled || undefined}\n        onPointerDown={drag.onPointerDown}\n        onKeyDown={(event) => {\n          if (\n            event.key === \"ContextMenu\" ||\n            (event.shiftKey && event.key === \"F10\")\n          )\n            return\n          if (disabled) return\n          if (\n            ![\n              \"ArrowUp\",\n              \"ArrowDown\",\n              \"ArrowLeft\",\n              \"ArrowRight\",\n              \"PageUp\",\n              \"PageDown\",\n              \"Home\",\n              \"End\",\n            ].includes(event.key)\n          )\n            return\n          onInteraction?.(event)\n          if (!keyboardActive.current) {\n            keyboardActive.current = true\n            onGestureStart?.()\n          }\n          handleKeyDown(event)\n        }}\n        onKeyUp={endKeyboardGesture}\n        onBlur={endKeyboardGesture}\n        {...rest}\n      >\n        {children({ dragging: drag.dragging, value01 })}\n      </div>\n    )\n\n    if (path === undefined && midi === undefined && renderFrame === undefined)\n      return control\n\n    const presentation: CcControlPresentation = {\n      label: ccLabel ?? rest[\"aria-label\"],\n      value: valueRawDisplayFn(valueRaw),\n      ...(path ? { path } : {}),\n      ...(ccRange\n        ? {\n            range: `${ccRange.min}–${ccRange.max}${ccRange.unit ? ` ${ccRange.unit}` : \"\"}`,\n          }\n        : {}),\n      ...ccPresentation,\n    }\n\n    return (\n      <CcControlChromeTarget\n        {...(path ? { path } : {})}\n        {...(midi ? { midi } : {})}\n        {...((ccLabel ?? rest[\"aria-label\"])\n          ? { label: ccLabel ?? rest[\"aria-label\"] }\n          : {})}\n        {...(ccRange ? { range: ccRange } : {})}\n        presentation={presentation}\n        as={ccFrameAs ?? \"span\"}\n        {...(ccFrameClassName ? { className: ccFrameClassName } : {})}\n        {...(ccFrameStyle ? { style: ccFrameStyle } : {})}\n        {...(renderCcBadge !== undefined ? { renderBadge: renderCcBadge } : {})}\n        {...(path ? { \"data-audio-param-path\": path } : {})}\n      >\n        {(chrome) => (renderFrame ? renderFrame({ control, chrome }) : control)}\n      </CcControlChromeTarget>\n    )\n  }\n)\n",
      "type": "registry:component",
      "target": "@ui/fader-headless.tsx"
    }
  ]
}
