{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "slider",
  "title": "Slider",
  "type": "registry:ui",
  "description": "Single-thumb slider — pick a value within a range. Optional tick marks, step size, and inline value display.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/slider/slider.ts",
      "content": "import { LitElement, css, html, nothing, type PropertyValues } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { computePosition } from '../../lib/position'\n\nexport interface SliderMark {\n  label: string\n  /** Inline style for the label, as a (camelCase or kebab-case) style object. */\n  style?: Record<string, string | number | undefined>\n}\n\ntype Tooltip = boolean | ((value: number) => string)\n\nconst PAGE_KEYS = ['PageUp', 'PageDown']\nconst ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']\nconst BACK_KEYS: Record<string, string[]> = {\n  'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],\n  'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'],\n  'from-bottom': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],\n  'from-top': ['Home', 'PageDown', 'ArrowUp', 'ArrowLeft'],\n}\n\n// --- Radix slider math, verbatim --------------------------------------------\nconst clamp = (v: number, [lo, hi]: [number, number]) => Math.min(hi, Math.max(lo, v))\nfunction getDecimalCount(value: number) {\n  if (!Number.isFinite(value)) return 0\n  const str = value.toString()\n  if (str.includes('e')) {\n    const [coefficient, exponent] = str.split('e')\n    return Math.max(0, (coefficient.split('.')[1] || '').length - Number(exponent))\n  }\n  return str.split('.')[1]?.length ?? 0\n}\nconst roundValue = (value: number, decimals: number) => Math.round(value * 10 ** decimals) / 10 ** decimals\nconst linearScale = (input: [number, number], output: [number, number]) => (value: number) => {\n  if (input[0] === input[1] || output[0] === output[1]) return output[0]\n  return output[0] + ((output[1] - output[0]) / (input[1] - input[0])) * (value - input[0])\n}\nfunction getNextStepValue(value: number, min: number, step: number, direction: number, multiplier: number) {\n  const decimals = getDecimalCount(step)\n  const stepsFromMin = (value - min) / step\n  const nearest = Math.round(stepsFromMin)\n  const aligned = roundValue(nearest * step + min, decimals) === roundValue(value, decimals)\n  const next = aligned ? nearest + multiplier * direction : direction > 0 ? Math.ceil(stepsFromMin) : Math.floor(stepsFromMin)\n  return roundValue(next * step + min, decimals)\n}\nfunction getThumbInBoundsOffset(width: number, percent: number, direction: number) {\n  const half = width / 2\n  return (half - linearScale([0, 50], [0, half])(percent) * direction) * direction\n}\nfunction getClosestValueIndex(values: number[], next: number) {\n  if (values.length === 1) return 0\n  const distances = values.map((v) => Math.abs(v - next))\n  return distances.indexOf(Math.min(...distances))\n}\nfunction thumbLabel(index: number, total: number) {\n  if (total > 2) return `Value ${index + 1} of ${total}`\n  if (total === 2) return ['Minimum', 'Maximum'][index]\n  return undefined\n}\n\n/** \"50\" / \"20,80\" / \"[20,80]\" → number[] */\nconst numberList = {\n  fromAttribute: (v: string | null) =>\n    v == null\n      ? undefined\n      : v\n          .replace(/[[\\]\\s]/g, '')\n          .split(',')\n          .filter(Boolean)\n          .map(Number),\n  toAttribute: (v: number[] | undefined) => v?.join(','),\n}\n/** Boolean props that default to true: only the literal `\"false\"` turns them off. */\nconst trueUnlessFalse = { fromAttribute: (v: string | null) => v !== 'false' }\n\nlet uid = 0\n\n/**\n * <uip-slider> — the registry Slider (Radix slider + tooltip) as ONE web component.\n *\n * Same props as React: `value` / `default-value` (number[]; attribute \"20,80\"),\n * `min`, `max`, `step`, `range`, `vertical`, `height`, `marks` (object property,\n * or JSON attribute), `tooltip` (boolean or formatter function; `tooltip=\"false\"`\n * hides it), `dots`, `reverse`, `included` (`included=\"false\"` hides the fill),\n * `size` ('small' | 'default'), `disabled`, `name`, `min-steps-between-thumbs`.\n *\n * Events (bubble, composed):\n *  - `input` + `value-change` (detail: number[]) on every value change (React `onValueChange`);\n *  - `change` + `value-commit` (detail: number[]) when a drag ends or a key commits (React `onValueCommit`).\n *\n * Form-associated: submits `name=value` (one thumb) or `name[]=…` per thumb (range), like\n * Radix's hidden inputs; resets with its form.\n *\n * Shadow-DOM adaptations: React's injected motion <style> is re-expressed as `motion-safe:`\n * utilities on the range/thumb; the tooltip is a native popover in the same shadow root\n * (thumb `aria-describedby` → tooltip) positioned with `computePosition`.\n */\nexport class UipSlider extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: { converter: numberList },\n    defaultValue: { attribute: 'default-value', converter: numberList },\n    min: { type: Number },\n    max: { type: Number },\n    step: { type: Number },\n    minStepsBetweenThumbs: { type: Number, attribute: 'min-steps-between-thumbs' },\n    range: { type: Boolean },\n    vertical: { type: Boolean, reflect: true },\n    height: {},\n    marks: { type: Object },\n    tooltip: { converter: trueUnlessFalse },\n    dots: { type: Boolean },\n    reverse: { type: Boolean },\n    included: { converter: trueUnlessFalse },\n    size: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    hoverIndex: { state: true },\n    focusIndex: { state: true },\n    dragging: { state: true },\n  }\n\n  value?: number[]\n  defaultValue?: number[]\n  min = 0\n  max = 100\n  step = 1\n  minStepsBetweenThumbs = 0\n  range = false\n  vertical = false\n  height?: string | number\n  marks?: Record<number, string | SliderMark>\n  tooltip: Tooltip = true\n  dots = false\n  reverse = false\n  included = true\n  size: 'small' | 'default' = 'default'\n  disabled = false\n  name?: string\n  accessibleLabel?: string\n  private hoverIndex = -1\n  private focusIndex = -1\n  private dragging = false\n\n  private activeIndex = 0\n  private initialValue: number[] = []\n  private valuesBeforeSlide: number[] = []\n  private pointerFocus = false\n  private rect?: DOMRect\n  private readonly uidBase = `uip-slider-${++uid}`\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'slider')\n  }\n\n  private get values(): number[] {\n    return this.value ?? []\n  }\n\n  protected willUpdate(changed: PropertyValues<this>) {\n    if (this.value === undefined) {\n      this.value = this.defaultValue ?? (this.range ? [this.min, this.max] : [this.min])\n    }\n    if (!this.hasUpdated) this.initialValue = [...this.value]\n    if (changed.has('value') || changed.has('name')) {\n      const vals = this.values\n      if (!this.name) this.internals.setFormValue(null)\n      else if (vals.length === 1) this.internals.setFormValue(String(vals[0]))\n      else {\n        const fd = new FormData()\n        vals.forEach((v) => fd.append(`${this.name}[]`, String(v)))\n        this.internals.setFormValue(fd)\n      }\n    }\n  }\n\n  // --- form callbacks -------------------------------------------------------\n  formResetCallback() {\n    this.value = [...this.initialValue]\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  // --- orientation helpers (Radix SliderHorizontal / SliderVertical) ----------\n  private get slideDirection() {\n    if (this.vertical) return this.reverse ? 'from-top' : 'from-bottom'\n    return this.reverse ? 'from-right' : 'from-left'\n  }\n  private get edges(): { start: string; end: string; direction: 1 | -1 } {\n    switch (this.slideDirection) {\n      case 'from-right':\n        return { start: 'right', end: 'left', direction: -1 }\n      case 'from-bottom':\n        return { start: 'bottom', end: 'top', direction: 1 }\n      case 'from-top':\n        return { start: 'top', end: 'bottom', direction: -1 }\n      default:\n        return { start: 'left', end: 'right', direction: 1 }\n    }\n  }\n  private percent(v: number) {\n    return clamp((100 / (this.max - this.min)) * (v - this.min), [0, 100])\n  }\n  private get root() {\n    return this.renderRoot?.querySelector<HTMLElement>('[data-slot=slider]')\n  }\n  private get thumbs() {\n    return [...(this.renderRoot?.querySelectorAll<HTMLElement>('[role=slider]') ?? [])]\n  }\n\n  // --- value updates (Radix updateValues) -----------------------------------\n  private updateValues(raw: number, atIndex: number, commit = false) {\n    const decimals = getDecimalCount(this.step)\n    const snapped = roundValue(Math.round((raw - this.min) / this.step) * this.step + this.min, decimals)\n    const nextValue = clamp(snapped, [this.min, this.max])\n    const prev = this.values\n    const next = [...prev]\n    next[atIndex] = nextValue\n    next.sort((a, b) => a - b)\n    const minGap = this.minStepsBetweenThumbs * this.step\n    if (minGap > 0 && Math.min(...next.slice(0, -1).map((v, i) => next[i + 1] - v)) < minGap) return\n    this.activeIndex = next.indexOf(nextValue)\n    if (String(next) === String(prev)) return\n    this.value = next\n    this.emit('input', 'value-change')\n    if (commit) this.emit('change', 'value-commit')\n    // Radix moves focus to the thumb that now holds the changed value.\n    this.updateComplete.then(() => this.thumbs[this.activeIndex]?.focus({ preventScroll: true }))\n  }\n\n  private emit(native: 'input' | 'change', custom: 'value-change' | 'value-commit') {\n    this.dispatchEvent(new Event(native, { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent(custom, { detail: [...this.values], bubbles: true, composed: true }))\n  }\n\n  private valueFromPointer(e: PointerEvent) {\n    const rect = (this.rect ??= this.root!.getBoundingClientRect())\n    if (this.vertical) {\n      const out: [number, number] = this.reverse ? [this.min, this.max] : [this.max, this.min]\n      return linearScale([0, rect.height], out)(e.clientY - rect.top)\n    }\n    const out: [number, number] = this.reverse ? [this.max, this.min] : [this.min, this.max]\n    return linearScale([0, rect.width], out)(e.clientX - rect.left)\n  }\n\n  // --- pointer (Radix SliderImpl) -------------------------------------------\n  private onPointerDown(e: PointerEvent) {\n    if (this.disabled || e.button !== 0) return\n    const target = e.target as HTMLElement\n    this.valuesBeforeSlide = [...this.values]\n    this.pointerFocus = true\n    target.setPointerCapture(e.pointerId)\n    e.preventDefault()\n    this.dragging = true\n    if (target.getAttribute('role') === 'slider') {\n      target.focus({ preventScroll: true })\n    } else {\n      const v = this.valueFromPointer(e)\n      this.updateValues(v, getClosestValueIndex(this.values, v))\n    }\n  }\n  private onPointerMove(e: PointerEvent) {\n    const target = e.target as HTMLElement\n    if (target.hasPointerCapture?.(e.pointerId)) this.updateValues(this.valueFromPointer(e), this.activeIndex)\n  }\n  private onPointerUp(e: PointerEvent) {\n    const target = e.target as HTMLElement\n    if (!target.hasPointerCapture?.(e.pointerId)) return\n    target.releasePointerCapture(e.pointerId)\n    this.rect = undefined\n    this.dragging = false\n    this.pointerFocus = false\n    if (String(this.values) !== String(this.valuesBeforeSlide)) this.emit('change', 'value-commit')\n  }\n\n  // --- keyboard (Radix SliderImpl + Slider) ---------------------------------\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.disabled) return\n    if (e.key === 'Home') {\n      this.updateValues(this.min, 0, true)\n    } else if (e.key === 'End') {\n      this.updateValues(this.max, this.values.length - 1, true)\n    } else if (PAGE_KEYS.includes(e.key) || ARROW_KEYS.includes(e.key)) {\n      const dir = BACK_KEYS[this.slideDirection].includes(e.key) ? -1 : 1\n      const skip = PAGE_KEYS.includes(e.key) || (e.shiftKey && ARROW_KEYS.includes(e.key))\n      const at = this.activeIndex\n      this.updateValues(getNextStepValue(this.values[at], this.min, this.step, dir, skip ? 10 : 1), at, true)\n    } else return\n    e.preventDefault()\n  }\n\n  private onThumbFocus(i: number) {\n    this.activeIndex = i\n    // Radix Tooltip opens on keyboard focus only (not on the focus a press causes).\n    if (!this.pointerFocus) this.focusIndex = i\n  }\n\n  // --- tooltip ----------------------------------------------------------------\n  private get openTip() {\n    if (this.tooltip === false || this.disabled) return -1\n    if (this.dragging) return this.activeIndex\n    return this.hoverIndex >= 0 ? this.hoverIndex : this.focusIndex\n  }\n  private format(v: number) {\n    return typeof this.tooltip === 'function' ? this.tooltip(v) : String(v)\n  }\n\n  protected updated() {\n    const open = this.openTip\n    this.renderRoot.querySelectorAll<HTMLElement>('[role=tooltip]').forEach((tip, i) => {\n      if (i !== open) {\n        if (tip.matches(':popover-open')) tip.hidePopover()\n        return\n      }\n      if (!tip.matches(':popover-open')) tip.showPopover()\n      const thumb = this.thumbs[i]\n      if (!thumb) return\n      const { style, side } = computePosition(thumb, tip, { side: this.vertical ? 'right' : 'top', sideOffset: 9 })\n      Object.assign(tip.style, style)\n      tip.dataset.side = side\n    })\n  }\n\n  render() {\n    const horizontal = !this.vertical\n    const vals = this.values\n    const { start, end, direction } = this.edges\n    const small = this.size === 'small'\n    const trackSize = small ? (horizontal ? 'h-1' : 'w-1') : horizontal ? 'h-1.5' : 'w-1.5'\n    const thumbSize = small ? 'size-3' : 'size-4'\n    const thumbPx = small ? 12 : 16\n    const orientation = horizontal ? 'horizontal' : 'vertical'\n    const pcts = vals.map((v) => this.percent(v))\n    const rangeStart = vals.length > 1 ? Math.min(...pcts) : 0\n    const rangeEnd = 100 - Math.max(...pcts)\n    const span = this.max - this.min\n\n    const markList = Object.entries(this.marks ?? {})\n      .map(([k, m]) => ({\n        value: Number(k),\n        label: typeof m === 'string' ? m : m.label,\n        style: typeof m === 'string' ? undefined : m.style,\n        pct: ((Number(k) - this.min) / span) * 100,\n      }))\n      .sort((a, b) => a.value - b.value)\n    const dotList: number[] = []\n    if (this.dots) for (let i = 0; i <= Math.floor(span / this.step); i++) dotList.push(this.min + i * this.step)\n\n    const thumbClass = cn(\n      'border-primary bg-background ring-ring/50 block shrink-0 rounded-full border shadow-sm touch-manipulation hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[disabled]:pointer-events-none',\n      thumbSize,\n      // React's injected motion stylesheet, as utilities.\n      'motion-safe:transition-[color,box-shadow,border-color,scale,left,right,top,bottom] motion-safe:duration-150 motion-safe:ease-emphasized motion-safe:active:not-data-[disabled]:scale-94 motion-safe:active:not-data-[disabled]:duration-100 motion-safe:group-active/slider:transition-[color,box-shadow,border-color,scale] motion-safe:group-active/slider:duration-100',\n    )\n    const heightStyle =\n      !horizontal && this.height != null\n        ? { height: typeof this.height === 'number' || /^\\d+(\\.\\d+)?$/.test(this.height) ? `${this.height}px` : this.height }\n        : {}\n\n    return html`<div class=${cn('relative w-full', !horizontal && 'flex flex-col items-center')} style=${styleMap(heightStyle)}>\n      <span\n        part=\"root\"\n        data-uipkge=\"\"\n        data-slot=\"slider\"\n        data-orientation=${orientation}\n        aria-disabled=${this.disabled ? 'true' : 'false'}\n        ?data-disabled=${this.disabled}\n        dir=\"ltr\"\n        class=${cn(\n          'relative flex touch-none select-none data-[disabled]:opacity-50',\n          horizontal ? 'w-full items-center' : 'h-full min-h-44 flex-col justify-center',\n          'group/slider',\n        )}\n        @pointerdown=${this.onPointerDown}\n        @pointermove=${this.onPointerMove}\n        @pointerup=${this.onPointerUp}\n        @lostpointercapture=${() => (this.dragging = false)}\n        @keydown=${this.onKeyDown}\n      >\n        <span\n          part=\"track\"\n          data-uipkge=\"\"\n          data-slot=\"slider-track\"\n          data-orientation=${orientation}\n          ?data-disabled=${this.disabled}\n          class=${cn('bg-muted relative grow overflow-hidden rounded-full', trackSize)}\n        >\n          ${this.included\n            ? html`<span\n                part=\"range\"\n                data-uipkge=\"\"\n                data-slot=\"slider-range\"\n                data-orientation=${orientation}\n                ?data-disabled=${this.disabled}\n                class=${cn(\n                  'bg-primary absolute',\n                  horizontal ? 'h-full' : 'w-full',\n                  'motion-safe:transition-[left,right,top,bottom] motion-safe:duration-150 motion-safe:ease-emphasized motion-safe:group-active/slider:transition-none',\n                )}\n                style=${styleMap({ [start]: `${rangeStart}%`, [end]: `${rangeEnd}%` })}\n              ></span>`\n            : nothing}\n        </span>\n        ${dotList.map(\n          (dot) => html`<div\n            class=${cn(\n              'border-primary/40 bg-background absolute rounded-full border',\n              horizontal ? 'top-1/2 size-1.5 -translate-y-1/2' : 'left-1/2 size-1.5 -translate-x-1/2',\n              small && 'size-1',\n              horizontal ? 'transform-[translateX(-50%)_translateY(-50%)]' : 'transform-[translateX(-50%)_translateY(50%)]',\n            )}\n            style=${styleMap(horizontal ? { left: `${((dot - this.min) / span) * 100}%` } : { bottom: `${((dot - this.min) / span) * 100}%` })}\n          ></div>`,\n        )}\n        ${markList.length\n          ? html`<div\n              class=${cn('pointer-events-none absolute', horizontal ? 'top-full mt-2.5 h-5 w-full' : 'top-0 left-full ml-3 h-full w-20')}\n            >\n              ${markList.map(\n                (m) => html`<span\n                  class=${cn(\n                    'text-muted-foreground absolute text-xs whitespace-nowrap',\n                    horizontal ? 'transform-[translateX(-50%)]' : 'transform-[translateY(50%)]',\n                  )}\n                  style=${styleMap({ ...m.style, ...(horizontal ? { left: `${m.pct}%` } : { bottom: `${m.pct}%` }) })}\n                  >${m.label}</span\n                >`,\n              )}\n            </div>`\n          : nothing}\n        ${vals.map((v, i) => {\n          const pct = this.percent(v)\n          const tipId = `${this.uidBase}-tip-${i}`\n          const open = this.openTip === i\n          return html`<span\n              class=${cn('absolute', horizontal ? '-translate-x-1/2' : 'translate-y-1/2')}\n              style=${styleMap({ [start]: `calc(${pct}% + ${getThumbInBoundsOffset(thumbPx, pct, direction)}px)` })}\n              ><span\n                part=\"thumb\"\n                role=\"slider\"\n                data-uipkge=\"\"\n                data-slot=\"slider-thumb\"\n                aria-label=${(vals.length === 1 ? this.accessibleLabel : undefined) ?? thumbLabel(i, vals.length) ?? nothing}\n                aria-valuemin=${this.min}\n                aria-valuenow=${v}\n                aria-valuemax=${this.max}\n                aria-orientation=${orientation}\n                aria-describedby=${open ? tipId : nothing}\n                data-orientation=${orientation}\n                data-state=${this.tooltip === false ? nothing : open ? 'instant-open' : 'closed'}\n                ?data-disabled=${this.disabled}\n                tabindex=${this.disabled ? nothing : 0}\n                class=${thumbClass}\n                @focus=${() => this.onThumbFocus(i)}\n                @blur=${() => (this.focusIndex = -1)}\n                @pointerenter=${(e: PointerEvent) => e.pointerType !== 'touch' && (this.hoverIndex = i)}\n                @pointerleave=${() => (this.hoverIndex = -1)}\n              ></span\n            ></span>\n            ${this.tooltip === false\n              ? nothing\n              : html`<div\n                  id=${tipId}\n                  role=\"tooltip\"\n                  popover=\"manual\"\n                  data-state=${open ? 'instant-open' : 'closed'}\n                  data-align=\"center\"\n                  class=\"bg-foreground text-background z-50 w-fit rounded-md px-2 py-1 text-xs fixed inset-auto m-0 overflow-visible\"\n                >\n                  ${this.format(v ?? 0)}\n                  <span\n                    class=${cn(\n                      'absolute',\n                      horizontal ? 'bottom-0 left-1/2 -translate-x-1/2 translate-y-full' : 'top-1/2 left-0 -translate-x-full -translate-y-1/2',\n                    )}\n                    ><svg\n                      class=\"bg-foreground fill-foreground size-2.5 rotate-45 rounded-[2px] block\"\n                      width=\"10\"\n                      height=\"5\"\n                      viewBox=\"0 0 30 10\"\n                      preserveAspectRatio=\"none\"\n                      aria-hidden=\"true\"\n                    >\n                      <polygon points=\"0,0 30,0 15,10\" /></svg\n                  ></span>\n                </div>`}`\n        })}\n      </span>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-slider') || customElements.define('uip-slider', UipSlider)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-slider': UipSlider\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/slider/slider.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
