448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
// frontend\src\components\ui\Slider.tsx
|
|
|
|
'use client'
|
|
|
|
import { useId, type SyntheticEvent, type CSSProperties } from 'react'
|
|
|
|
type Props = {
|
|
id?: string
|
|
|
|
value: number
|
|
onChange: (value: number) => void
|
|
onChangeEnd?: (value: number) => void
|
|
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
disabled?: boolean
|
|
|
|
label?: string
|
|
hint?: string
|
|
|
|
valueLabel?: string
|
|
valueFormatter?: (value: number) => string
|
|
showValueLabel?: boolean
|
|
|
|
showMinMaxLabels?: boolean
|
|
minLabel?: string
|
|
maxLabel?: string
|
|
|
|
showReset?: boolean
|
|
resetValue?: number
|
|
resetLabel?: string
|
|
|
|
compact?: boolean
|
|
|
|
className?: string
|
|
inputClassName?: string
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, value))
|
|
}
|
|
|
|
export default function Slider({
|
|
id,
|
|
value,
|
|
onChange,
|
|
onChangeEnd,
|
|
min = 0,
|
|
max = 100,
|
|
step = 1,
|
|
disabled = false,
|
|
label,
|
|
hint,
|
|
valueLabel,
|
|
valueFormatter,
|
|
showValueLabel = false,
|
|
showMinMaxLabels = false,
|
|
minLabel,
|
|
maxLabel,
|
|
showReset = false,
|
|
resetValue,
|
|
resetLabel = 'Reset',
|
|
compact = false,
|
|
className = '',
|
|
inputClassName = '',
|
|
}: Props) {
|
|
const reactId = useId()
|
|
const inputId = id ?? `slider-${reactId}`
|
|
|
|
const safeValue = Number.isFinite(value) ? value : min
|
|
const clamped = clamp(safeValue, min, max)
|
|
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
|
|
|
|
const resolvedValueLabel =
|
|
valueLabel ?? (valueFormatter ? valueFormatter(clamped) : String(clamped))
|
|
|
|
const resolvedMinLabel =
|
|
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
|
|
|
|
const resolvedMaxLabel =
|
|
maxLabel ?? (valueFormatter ? valueFormatter(max) : String(max))
|
|
|
|
const canReset =
|
|
showReset &&
|
|
typeof resetValue === 'number' &&
|
|
Number.isFinite(resetValue)
|
|
|
|
const trackH = compact ? 4 : 8
|
|
const thumbSize = compact ? 14 : 16
|
|
const inputH = compact ? 14 : 16
|
|
const thumbOffset = -((thumbSize - trackH) / 2)
|
|
|
|
const handleChange = (next: number) => {
|
|
onChange(clamp(next, min, max))
|
|
}
|
|
|
|
const handleCommit = (e: SyntheticEvent<HTMLInputElement>) => {
|
|
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
|
}
|
|
|
|
const handleReset = () => {
|
|
if (!canReset) return
|
|
const next = clamp(resetValue!, min, max)
|
|
onChange(next)
|
|
onChangeEnd?.(next)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
compact
|
|
? 'flex w-full min-w-0 items-center gap-3 rounded-md border border-gray-200/70 bg-white/60 px-3 h-9 shadow-sm dark:border-white/10 dark:bg-white/5'
|
|
: 'w-full rounded-xl border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
|
|
className,
|
|
].join(' ')}
|
|
>
|
|
{compact ? (
|
|
<>
|
|
{label ? (
|
|
<label
|
|
htmlFor={inputId}
|
|
className="shrink-0 text-sm font-medium leading-none text-gray-900 dark:text-gray-100"
|
|
>
|
|
{label}
|
|
</label>
|
|
) : null}
|
|
|
|
<div className="relative min-w-0 flex-1">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
|
|
<div
|
|
className="absolute left-0 right-0 rounded-full bg-gray-200/90 dark:bg-white/10"
|
|
style={{
|
|
height: `${trackH}px`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
|
|
style={{
|
|
width: `${percent}%`,
|
|
height: `${trackH}px`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<input
|
|
id={inputId}
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={clamped}
|
|
disabled={disabled}
|
|
aria-label={label ?? 'Slider'}
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
aria-valuenow={clamped}
|
|
className={[
|
|
`
|
|
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
|
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
focus:outline-none touch-pan-x
|
|
|
|
[&::-webkit-slider-runnable-track]:bg-transparent
|
|
[&::-webkit-slider-runnable-track]:appearance-none
|
|
|
|
[&::-webkit-slider-thumb]:appearance-none
|
|
[&::-webkit-slider-thumb]:rounded-full
|
|
[&::-webkit-slider-thumb]:border
|
|
[&::-webkit-slider-thumb]:border-white/80
|
|
[&::-webkit-slider-thumb]:bg-indigo-500
|
|
[&::-webkit-slider-thumb]:shadow-sm
|
|
dark:[&::-webkit-slider-thumb]:border-gray-950
|
|
dark:[&::-webkit-slider-thumb]:bg-indigo-400
|
|
|
|
[&::-moz-range-track]:bg-transparent
|
|
[&::-moz-range-track]:border-0
|
|
|
|
[&::-moz-range-thumb]:rounded-full
|
|
[&::-moz-range-thumb]:border
|
|
[&::-moz-range-thumb]:border-white/80
|
|
[&::-moz-range-thumb]:bg-indigo-500
|
|
[&::-moz-range-thumb]:shadow-sm
|
|
dark:[&::-moz-range-thumb]:border-gray-950
|
|
dark:[&::-moz-range-thumb]:bg-indigo-400
|
|
`,
|
|
inputClassName,
|
|
].join(' ')}
|
|
style={
|
|
{
|
|
height: `${inputH}px`,
|
|
WebkitAppearance: 'none',
|
|
appearance: 'none',
|
|
['--slider-track-h' as any]: `${trackH}px`,
|
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
|
} as CSSProperties
|
|
}
|
|
onChange={(e) => handleChange(Number(e.target.value))}
|
|
onPointerUp={handleCommit}
|
|
onMouseUp={handleCommit}
|
|
onTouchEnd={handleCommit}
|
|
onKeyUp={(e) => {
|
|
if (
|
|
e.key === 'ArrowLeft' ||
|
|
e.key === 'ArrowRight' ||
|
|
e.key === 'ArrowUp' ||
|
|
e.key === 'ArrowDown' ||
|
|
e.key === 'Home' ||
|
|
e.key === 'End' ||
|
|
e.key === 'PageUp' ||
|
|
e.key === 'PageDown'
|
|
) {
|
|
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<style>{`
|
|
input[type='range']::-webkit-slider-runnable-track {
|
|
height: var(--slider-track-h);
|
|
}
|
|
|
|
input[type='range']::-webkit-slider-thumb {
|
|
width: var(--slider-thumb-size);
|
|
height: var(--slider-thumb-size);
|
|
margin-top: var(--slider-thumb-offset);
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
input[type='range']::-moz-range-track {
|
|
height: var(--slider-track-h);
|
|
}
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
width: var(--slider-thumb-size);
|
|
height: var(--slider-thumb-size);
|
|
appearance: none;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
|
|
{showValueLabel ? (
|
|
<div className="shrink-0 text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400">
|
|
{resolvedValueLabel}
|
|
</div>
|
|
) : null}
|
|
|
|
{canReset ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={handleReset}
|
|
className="
|
|
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium
|
|
text-gray-700 ring-1 ring-gray-200 transition-colors
|
|
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
|
|
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
|
|
"
|
|
>
|
|
{resetLabel}
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
{(label || hint || showValueLabel || canReset) ? (
|
|
<div className="mb-2 flex items-start justify-between gap-3">
|
|
{(label || hint) ? (
|
|
<div className="min-w-0">
|
|
{label ? (
|
|
<label
|
|
htmlFor={inputId}
|
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
|
>
|
|
{label}
|
|
</label>
|
|
) : null}
|
|
|
|
{hint ? (
|
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
|
{hint}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{(showValueLabel || canReset) ? (
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{showValueLabel ? (
|
|
<div className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium tabular-nums text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
|
{resolvedValueLabel}
|
|
</div>
|
|
) : null}
|
|
|
|
{canReset ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={handleReset}
|
|
className="
|
|
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
|
|
text-gray-700 ring-1 ring-gray-200 transition-colors
|
|
hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50
|
|
dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10
|
|
"
|
|
>
|
|
{resetLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="relative min-w-0">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 right-0">
|
|
<div
|
|
className="absolute left-0 right-0 rounded-full bg-gray-200 dark:bg-white/10"
|
|
style={{
|
|
height: `${trackH}px`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
|
|
style={{
|
|
width: `${percent}%`,
|
|
height: `${trackH}px`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<input
|
|
id={inputId}
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={clamped}
|
|
disabled={disabled}
|
|
aria-label={label ?? 'Slider'}
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
aria-valuenow={clamped}
|
|
className={[
|
|
`
|
|
relative z-10 block w-full cursor-pointer appearance-none bg-transparent
|
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
focus:outline-none touch-pan-x
|
|
|
|
[&::-webkit-slider-runnable-track]:bg-transparent
|
|
[&::-webkit-slider-runnable-track]:appearance-none
|
|
|
|
[&::-webkit-slider-thumb]:appearance-none
|
|
[&::-webkit-slider-thumb]:rounded-full
|
|
[&::-webkit-slider-thumb]:border
|
|
[&::-webkit-slider-thumb]:border-white/80
|
|
[&::-webkit-slider-thumb]:bg-indigo-500
|
|
[&::-webkit-slider-thumb]:shadow-sm
|
|
dark:[&::-webkit-slider-thumb]:border-gray-950
|
|
dark:[&::-webkit-slider-thumb]:bg-indigo-400
|
|
|
|
[&::-moz-range-track]:bg-transparent
|
|
[&::-moz-range-track]:border-0
|
|
|
|
[&::-moz-range-thumb]:rounded-full
|
|
[&::-moz-range-thumb]:border
|
|
[&::-moz-range-thumb]:border-white/80
|
|
[&::-moz-range-thumb]:bg-indigo-500
|
|
[&::-moz-range-thumb]:shadow-sm
|
|
dark:[&::-moz-range-thumb]:border-gray-950
|
|
dark:[&::-moz-range-thumb]:bg-indigo-400
|
|
`,
|
|
inputClassName,
|
|
].join(' ')}
|
|
style={
|
|
{
|
|
height: `${thumbSize}px`,
|
|
WebkitAppearance: 'none',
|
|
appearance: 'none',
|
|
['--slider-track-h' as any]: `${trackH}px`,
|
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
|
} as CSSProperties
|
|
}
|
|
onChange={(e) => handleChange(Number(e.target.value))}
|
|
onPointerUp={handleCommit}
|
|
onMouseUp={handleCommit}
|
|
onTouchEnd={handleCommit}
|
|
onKeyUp={(e) => {
|
|
if (
|
|
e.key === 'ArrowLeft' ||
|
|
e.key === 'ArrowRight' ||
|
|
e.key === 'ArrowUp' ||
|
|
e.key === 'ArrowDown' ||
|
|
e.key === 'Home' ||
|
|
e.key === 'End' ||
|
|
e.key === 'PageUp' ||
|
|
e.key === 'PageDown'
|
|
) {
|
|
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<style>{`
|
|
input[type='range']::-webkit-slider-runnable-track {
|
|
height: var(--slider-track-h);
|
|
}
|
|
|
|
input[type='range']::-webkit-slider-thumb {
|
|
width: var(--slider-thumb-size);
|
|
height: var(--slider-thumb-size);
|
|
margin-top: var(--slider-thumb-offset);
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
input[type='range']::-moz-range-track {
|
|
height: var(--slider-track-h);
|
|
}
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
width: var(--slider-thumb-size);
|
|
height: var(--slider-thumb-size);
|
|
appearance: none;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
|
|
{showMinMaxLabels ? (
|
|
<div className="mt-1 flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
|
|
<span>{resolvedMinLabel}</span>
|
|
<span>{resolvedMaxLabel}</span>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
} |