{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-meter",
  "type": "registry:component",
  "title": "Range Meter",
  "description": "A reading on a ruler of ticks that rises where the value is, with the number above it.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/range-meter.tsx",
      "type": "registry:component",
      "target": "@components/panelui/range-meter.tsx",
      "content": "'use client';\n\n/**\n * RangeMeter — where a reading sits between its lowest and its highest, on a\n * ruler of ticks that rises under the value.\n *\n * ```tsx\n * <RangeMeter.Label>Response time</RangeMeter.Label>\n * <RangeMeter min={80} max={600} value={time} onValueChange={setTime} marker={300} />\n * <RangeMeter.Scale start=\"80 ms\" end=\"600 ms\" />\n * ```\n *\n * ## The ruler swells where the reading is\n *\n * The track is a row of ticks. The ones up to the reading are lit, and the\n * ticks round the reading stand taller, falling away on either side, so the\n * value is a rise in the ruler and not a knob sitting on a line. Dragging\n * moves the rise along, and the ticks it leaves settle back behind it.\n *\n * ## The number rides above\n *\n * The value is written in a tag over the rise, so the place on the ruler and\n * the figure are one thing to read. The tag is hung from the point it marks\n * in proportion to how far along that point is: from its left edge at the low\n * end, its middle half way, its right edge at the high end. It is always over\n * the reading and never outside the ruler.\n *\n * ## A reading or a control\n *\n * With `onValueChange` it is a slider: drag it, or use the arrow keys. Without\n * it, the same drawing is a meter that shows a value and cannot be moved.\n * Either way a screen reader is given the value and the range it is in.\n *\n * ## The colour is the theme's\n *\n * `color` names one of the theme's roles. Changing it as the value crosses a\n * threshold is how a meter says the reading has gone from fine to not.\n */\nimport {\n  useCallback,\n  useRef,\n  type ComponentProps,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\n/** Which of the theme's roles the lit ticks and the tag are drawn in. */\nexport type RangeMeterColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst rangeMeterVariants = tv({\n  slots: {\n    // Room above the ruler for the tag, so the tag is inside the control's\n    // own box and cannot be clipped by whatever the meter sits in.\n    root: 'relative w-full touch-none select-none rounded-lg pt-9 outline-none rtl:[--pu-dir:-1] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background',\n    ruler: 'relative flex h-10 items-end justify-between',\n    tick: 'w-[3px] rounded-full transition-[height,background-color,opacity,box-shadow] duration-200 ease-out motion-reduce:transition-none',\n    marker:\n      'pointer-events-none absolute -bottom-2.5 size-1.5 -translate-x-1/2 rotate-45 rounded-[1px] bg-foreground rtl:translate-x-1/2',\n    // Siblings of the meter, written beside it rather than inside it.\n    label: 'text-sm font-medium',\n    scale: 'flex justify-between text-xs text-muted-foreground',\n    tag: 'pointer-events-none absolute top-0 flex h-7 items-center gap-1 whitespace-nowrap rounded-full bg-[var(--pu-color)] px-2.5 text-xs font-semibold tabular-nums text-[var(--pu-on-color)] shadow-[0_6px_18px_-4px_color-mix(in_oklab,var(--pu-color)_70%,transparent)] transition-[background-color,box-shadow,scale] duration-200 motion-reduce:transition-none [&_svg]:size-3.5 [&_svg]:shrink-0',\n  },\n  variants: {\n    /*\n     * A role and what is written on it. `primary` has a partner in every\n     * theme. The status roles are mid-tones, light enough that the theme\n     * writes on them in near-black whether the page is light or dark.\n     */\n    color: {\n      primary: { root: '[--pu-color:var(--primary)] [--pu-on-color:var(--primary-foreground)]' },\n      info: { root: '[--pu-color:var(--info)] [--pu-on-color:var(--color-neutral-900)]' },\n      success: { root: '[--pu-color:var(--success)] [--pu-on-color:var(--color-neutral-900)]' },\n      warning: { root: '[--pu-color:var(--warning)] [--pu-on-color:var(--color-neutral-900)]' },\n      destructive: {\n        root: '[--pu-color:var(--destructive)] [--pu-on-color:var(--color-neutral-900)]',\n      },\n    },\n    lit: {\n      true: { tick: 'bg-[var(--pu-color)]' },\n      false: { tick: 'bg-muted-foreground/25' },\n    },\n    // The tick the reading is on, which is the top of the rise.\n    crest: {\n      true: {\n        tick: 'shadow-[0_0_10px_color-mix(in_oklab,var(--pu-color)_70%,transparent)]',\n      },\n    },\n    // The marker hangs under the ruler, and the control makes room for it.\n    marked: {\n      true: { root: 'pb-4' },\n    },\n    interactive: {\n      true: { root: 'cursor-grab active:cursor-grabbing active:[&_[data-tag]]:scale-110' },\n    },\n  },\n});\n\nexport interface RangeMeterProps extends Omit<ComponentProps<'div'>, 'onChange' | 'color'> {\n  /** The low end of the ruler. */\n  min?: number;\n  /** The high end of the ruler. */\n  max?: number;\n  /** The reading: where the ruler rises and the number written above it. */\n  value: number;\n  /**\n   * Called with the new value as the ruler is dragged or moved with the\n   * keyboard. Passing this makes it a slider; without it, it is a meter.\n   */\n  onValueChange?: (value: number) => void;\n  /** How much one arrow key press moves the value, and what a drag snaps to. */\n  step?: number;\n  /** A value to mark under the ruler, such as a target. */\n  marker?: number;\n  /** How many ticks the ruler is drawn with. */\n  ticks?: number;\n  /** Which of the theme's roles the lit ticks and the tag are drawn in. */\n  color?: RangeMeterColor;\n  /** Writes the number in the tag. Defaults to the value as it is. */\n  formatValue?: (value: number) => ReactNode;\n  /** What a screen reader calls it. */\n  label?: string;\n  /** What a screen reader says for the value: \"240 milliseconds\". */\n  valueText?: string;\n  /** Drawn in the tag before the number: an icon. */\n  children?: ReactNode;\n}\n\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\n/** How many ticks either side of the reading the rise spreads over. */\nconst SPREAD = 2.6;\n/** A resting tick's height, as a share of the ruler's. */\nconst REST = 0.34;\n\nfunction RangeMeterRoot({\n  min = 0,\n  max = 100,\n  value,\n  onValueChange,\n  step = 1,\n  marker,\n  ticks = 41,\n  color = 'primary',\n  formatValue,\n  label = 'Reading',\n  valueText,\n  className,\n  children,\n  render,\n  ...props\n}: RangeMeterProps & RenderProp<'div'>) {\n  const interactive = onValueChange !== undefined;\n  const slots = rangeMeterVariants({ color, interactive, marked: marker !== undefined });\n  const ruler = useRef<HTMLDivElement>(null);\n  const dragging = useRef(false);\n\n  const count = Math.max(2, Math.round(ticks));\n  const span = max - min || 1;\n  const share = clamp((value - min) / span, 0, 1);\n  // Where the reading falls among the ticks, as a position between two of\n  // them, so the rise slides along the ruler and does not hop tick to tick.\n  const at = share * (count - 1);\n  const crest = Math.round(at);\n\n  // Rounded to the step's own precision, so 0.1 steps do not come back as\n  // 0.30000000000000004.\n  const places = (String(step).split('.')[1] ?? '').length;\n  const settle = useCallback(\n    (raw: number) => clamp(Number((Math.round(raw / step) * step).toFixed(places)), min, max),\n    [step, places, min, max]\n  );\n\n  const fromPointer = useCallback(\n    (clientX: number) => {\n      const node = ruler.current;\n      const box = node?.getBoundingClientRect();\n      if (!node || !box || box.width === 0) return;\n      const rtl = getComputedStyle(node).direction === 'rtl';\n      const along = rtl ? box.right - clientX : clientX - box.left;\n      onValueChange?.(settle(min + clamp(along / box.width, 0, 1) * span));\n    },\n    [min, span, settle, onValueChange]\n  );\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!interactive || event.button !== 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    dragging.current = true;\n    fromPointer(event.clientX);\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (dragging.current) fromPointer(event.clientX);\n  };\n\n  const endDrag = () => {\n    dragging.current = false;\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';\n    const page = Math.max(step, Math.round(span / 10 / step) * step);\n    const moves: Record<string, number> = {\n      ArrowLeft: value + (rtl ? step : -step),\n      ArrowRight: value + (rtl ? -step : step),\n      ArrowDown: value - step,\n      ArrowUp: value + step,\n      PageDown: value - page,\n      PageUp: value + page,\n      Home: min,\n      End: max,\n    };\n    const next = moves[event.key];\n    if (next === undefined) return;\n    event.preventDefault();\n    onValueChange(clamp(Number(next.toFixed(places)), min, max));\n  };\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        role: interactive ? 'slider' : 'meter',\n        'aria-label': label,\n        'aria-valuemin': min,\n        'aria-valuemax': max,\n        'aria-valuenow': value,\n        'aria-valuetext': valueText,\n        'aria-orientation': interactive ? 'horizontal' : undefined,\n        tabIndex: interactive ? 0 : undefined,\n        className: slots.root({ className }),\n        onPointerDown: onPointerDown,\n        onPointerMove: onPointerMove,\n        onPointerUp: endDrag,\n        onPointerCancel: endDrag,\n        onKeyDown: onKeyDown,\n        children: (\n          <>\n            <span\n              data-tag=\"\"\n              className={slots.tag()}\n              style={{\n                insetInlineStart: `${(share * 100).toFixed(2)}%`,\n                // Hung by the same share of its own width as the point is along the\n                // ruler, which keeps it over the point and inside the ends.\n                translate: `calc(${share.toFixed(4)} * -100% * var(--pu-dir, 1))`,\n              }}\n            >\n              {children}\n              {formatValue ? formatValue(value) : value}\n            </span>\n\n            <div ref={ruler} aria-hidden=\"true\" className={slots.ruler()}>\n              {Array.from({ length: count }, (_, index) => {\n                const distance = (index - at) / SPREAD;\n                // A bell: full height at the reading, resting height a few ticks out.\n                const rise = Math.exp(-distance * distance);\n                const lit = index <= at + 0.001;\n                const part = rangeMeterVariants({ lit, crest: index === crest });\n                return (\n                  <span\n                    key={index}\n                    className={part.tick()}\n                    style={{\n                      // Rounded, so the server and the browser write the same\n                      // string for it and the page hydrates without a mismatch.\n                      height: `${((REST + (1 - REST) * rise) * 100).toFixed(1)}%`,\n                      // Lit ticks brighten towards the reading, like a tail behind it.\n                      opacity: lit\n                        ? Number((0.35 + 0.65 * (crest === 0 ? 1 : Math.min(1, index / crest))).toFixed(3))\n                        : undefined,\n                    }}\n                  />\n                );\n              })}\n              {marker !== undefined ? (\n                <span\n                  className={slots.marker()}\n                  style={{ insetInlineStart: `${(clamp((marker - min) / span, 0, 1) * 100).toFixed(2)}%` }}\n                />\n              ) : null}\n            </div>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nRangeMeterRoot.displayName = 'RangeMeter';\n\nexport interface RangeMeterLabelProps extends ComponentProps<'p'> {}\n\n/**\n * The meter's name, written over it. The meter's own `label` is what a screen\n * reader says; this is what everyone else reads.\n */\nfunction RangeMeterLabel({ className, render, ...props }: RangeMeterLabelProps & RenderProp<'p'>) {\n  return useRender({\n    defaultTagName: 'p',\n    render,\n    props: mergeProps(\n      { 'data-slot': 'range-meter-label', className: rangeMeterVariants().label({ className }) },\n      props\n    ),\n  });\n}\nRangeMeterLabel.displayName = 'RangeMeter.Label';\n\nexport interface RangeMeterScaleProps extends Omit<ComponentProps<'div'>, 'children'> {\n  /** The word or figure at the low end. */\n  start: ReactNode;\n  /** The word or figure at the high end. */\n  end: ReactNode;\n}\n\n/** What the two ends of the ruler stand for, written under it at either side. */\nfunction RangeMeterScale({ start, end, className, render, ...props }: RangeMeterScaleProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'range-meter-scale',\n        className: rangeMeterVariants().scale({ className }),\n        children: (\n          <>\n            <span>{start}</span>\n            <span>{end}</span>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nRangeMeterScale.displayName = 'RangeMeter.Scale';\n\nexport const RangeMeter = Object.assign(RangeMeterRoot, {\n  Label: RangeMeterLabel,\n  Scale: RangeMeterScale,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { RangeMeterLabel, RangeMeterScale };\n"
    }
  ]
}
