2026-05-28 15:05:53 +02:00

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>
)
}