"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
import { Slider } from "@/components/ui/slider"
const TICK_COUNT = 5
export type SliderInputProps = React.ComponentProps<"div"> & {
value: number
onValueChange: (value: number) => void
min: number
max: number
step?: number
/** How the number reads in the field and on the scale; grouped digits by default. */
format?: (value: number) => string
label?: React.ReactNode
/** Draws an evenly spaced scale with the ends labelled. */
showTicks?: boolean
/** Sits inside the number field after the value, e.g. "%" or "ms". */
unit?: string
}
/** A slider and a number field bound to the same value, for a range you also want to type into. */
function SliderInput({
className,
value,
onValueChange,
min,
max,
step = 1,
format,
label,
showTicks = false,
unit,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
...props
}: SliderInputProps) {
const labelId = React.useId()
const inputId = React.useId()
const readValue = format ?? ((n: number) => formatNumber(n))
// One name for both controls: the visible label, or the group's own
// aria-label or aria-labelledby when there is none.
// - The number field takes the label through its <label for>.
// - The slider's focus lands on the range input in its thumb. A name with
// words of its own (a text label, an aria-label) goes on the thumb as its
// aria-label, and the pair sits in a group of that name.
// - A name that is an element (a label node, an aria-labelledby) can only
// reach the thumb by id, through the slider's aria-labelledby, which Base
// UI also puts on the slider's own group. That group then carries the
// name, and the pair's container is not made a second group of it.
const labelledBy = label ? labelId : ariaLabelledBy
const labelText = label
? typeof label === "string" || typeof label === "number"
? String(label)
: undefined
: ariaLabel
const sliderNamesGroup = !labelText && Boolean(labelledBy)
return (
<div
data-slot="slider-input"
role={sliderNamesGroup ? undefined : "group"}
aria-labelledby={sliderNamesGroup ? undefined : labelledBy}
aria-label={labelledBy ? undefined : labelText}
className={cn("flex w-full flex-col gap-2", className)}
{...props}
>
<div className="flex items-center justify-between gap-3">
{label ? (
<Label id={labelId} htmlFor={inputId}>
{label}
</Label>
) : (
<span />
)}
<NumberInput
id={inputId}
aria-labelledby={label ? undefined : ariaLabelledBy}
aria-label={label || ariaLabelledBy ? undefined : labelText}
value={value}
min={min}
max={max}
step={step}
format={format}
suffix={unit}
onValueChange={(next) => onValueChange(next ?? min)}
className="w-24 shrink-0"
/>
</div>
<Slider
aria-label={labelText}
aria-labelledby={labelText ? undefined : labelledBy}
value={value}
min={min}
max={max}
step={step}
onValueChange={(next) => onValueChange(typeof next === "number" ? next : next[0])}
/>
{showTicks ? (
<div data-slot="slider-input-scale" className="flex flex-col gap-1">
<div aria-hidden="true" className="flex items-center justify-between">
{Array.from({ length: TICK_COUNT }, (_, index) => (
<span key={index} className="h-1 w-px bg-border" />
))}
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground tabular-nums">
<span>{readValue(min)}</span>
<span>{readValue(max)}</span>
</div>
</div>
) : null}
</div>
)
}
export { SliderInput }