151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
|
||
import { Slider as RSlider } from 'radix-ui';
|
||
|
||
type Mark = { value: number; label?: string };
|
||
type MarksProp = boolean | Array<number | Mark>;
|
||
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
|
||
|
||
type KnobStyle = 'square' | 'round';
|
||
|
||
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
|
||
/**
|
||
* Step marks.
|
||
* - `true` — auto-generate one mark per `step` between `min` and `max`.
|
||
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
|
||
*/
|
||
marks?: MarksProp;
|
||
/**
|
||
* Where to draw the notch ticks relative to the track.
|
||
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
|
||
* (labels still render when provided). No effect without `marks`.
|
||
*/
|
||
notches?: NotchPlacement;
|
||
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
|
||
knobStyle?: KnobStyle;
|
||
/**
|
||
* Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
|
||
* Explicitly setting this always overrides the default.
|
||
*/
|
||
animated?: boolean;
|
||
className?: string;
|
||
};
|
||
|
||
function resolveMarks(
|
||
marks: MarksProp,
|
||
min: number,
|
||
max: number,
|
||
step: number,
|
||
): Mark[] {
|
||
if (Array.isArray(marks)) {
|
||
return marks
|
||
.map((m) => (typeof m === 'number' ? { value: m } : m))
|
||
.filter((m) => m.value >= min && m.value <= max);
|
||
}
|
||
if (marks !== true) return [];
|
||
if (!(step > 0) || max <= min) return [];
|
||
const count = Math.floor((max - min) / step);
|
||
// Guard against absurd notch counts (e.g. step=1 over a 0–1000 range).
|
||
if (count < 1 || count > 100) return [];
|
||
return Array.from({ length: count + 1 }, (_, i) => ({ value: min + i * step }));
|
||
}
|
||
|
||
const percent = (value: number, min: number, max: number) =>
|
||
max === min ? 0 : (value - min) / (max - min);
|
||
|
||
const NotchLayer = ({
|
||
marks,
|
||
min,
|
||
max,
|
||
side,
|
||
}: {
|
||
marks: Mark[];
|
||
min: number;
|
||
max: number;
|
||
side: 'top' | 'bottom';
|
||
}) => (
|
||
<div className={`modern-sk-slider__notches modern-sk-slider__notches--${side}`} aria-hidden>
|
||
{marks.map((mark) => (
|
||
<span
|
||
key={mark.value}
|
||
className="modern-sk-slider__notch"
|
||
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
export const Slider = ({
|
||
marks,
|
||
notches = 'bottom',
|
||
knobStyle = 'square',
|
||
animated,
|
||
min = 0,
|
||
max = 100,
|
||
step = 1,
|
||
className,
|
||
...props
|
||
}: SliderProps) => {
|
||
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
|
||
const hasMarks = resolved.length > 0;
|
||
const hasLabels = resolved.some((m) => m.label != null);
|
||
const showTop = hasMarks && (notches === 'top' || notches === 'both');
|
||
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
|
||
const isAnimated = animated !== undefined ? animated : hasMarks;
|
||
|
||
const cls = [
|
||
'modern-sk-slider',
|
||
`modern-sk-slider--knob-${knobStyle}`,
|
||
isAnimated && 'modern-sk-slider--animated',
|
||
hasMarks && 'modern-sk-slider--has-marks',
|
||
hasLabels && 'modern-sk-slider--has-labels',
|
||
showTop && 'modern-sk-slider--notch-top',
|
||
showBottom && 'modern-sk-slider--notch-bottom',
|
||
className,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
|
||
return (
|
||
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
|
||
<RSlider.Track className="modern-sk-slider__track">
|
||
<RSlider.Range className="modern-sk-slider__range" />
|
||
{showTop && <NotchLayer marks={resolved} min={min} max={max} side="top" />}
|
||
{showBottom && <NotchLayer marks={resolved} min={min} max={max} side="bottom" />}
|
||
{hasLabels && (
|
||
<div className="modern-sk-slider__labels" aria-hidden>
|
||
{resolved.map((mark) =>
|
||
mark.label != null ? (
|
||
<span
|
||
key={mark.value}
|
||
className="modern-sk-slider__label"
|
||
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||
>
|
||
{mark.label}
|
||
</span>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
)}
|
||
</RSlider.Track>
|
||
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
|
||
</RSlider.Root>
|
||
);
|
||
};
|
||
|
||
export const Stepper = ({
|
||
onDecrement,
|
||
onIncrement,
|
||
}: {
|
||
onDecrement: () => void;
|
||
onIncrement: () => void;
|
||
}) => (
|
||
<div className="modern-sk-stepper">
|
||
<button type="button" onClick={onDecrement} aria-label="Decrease">
|
||
−
|
||
</button>
|
||
<button type="button" onClick={onIncrement} aria-label="Increase">
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|