Files
modern-sk/src/components/slider/index.tsx
T
2026-06-02 15:26:32 +03:00

151 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);