visuals, fixes & storybook upd

This commit is contained in:
2026-06-01 01:09:55 +03:00
parent a5d2742c7c
commit 4919bc26e5
19 changed files with 1704 additions and 342 deletions
+109 -43
View File
@@ -1,59 +1,125 @@
import { type ComponentPropsWithoutRef } from 'react';
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
import { Slider as RSlider } from 'radix-ui';
type Step = { value: number; label?: string };
type Mark = { value: number; label?: string };
type MarksProp = boolean | Array<number | Mark>;
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
type SliderProps = ComponentPropsWithoutRef<typeof RSlider.Root> & {
steps?: number | Step[];
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;
className?: string;
};
function resolveSteps(steps: number | Step[], min: number, max: number): Step[] {
if (Array.isArray(steps)) return steps;
if (steps < 2) return [];
return Array.from({ length: steps }, (_, i) => ({
value: min + (i / (steps - 1)) * (max - min),
}));
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 }));
}
export const Slider = ({ steps, min = 0, max = 100, ...props }: SliderProps) => {
const resolved = steps != null ? resolveSteps(steps, min, max) : [];
const hasSteps = resolved.length > 0;
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',
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 cls = [
'modern-sk-slider',
`modern-sk-slider--knob-${knobStyle}`,
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={`modern-sk-slider${hasSteps ? ' modern-sk-slider--has-steps' : ''}`}
min={min}
max={max}
{...props}
>
<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" />
{hasSteps && resolved.map((step) => (
<div
key={step.value}
className="modern-sk-slider__step-dot"
aria-hidden
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
/>
))}
{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" />
{hasSteps && (
<div className="modern-sk-slider__steps" aria-hidden>
{resolved.map((step) => (
<div
key={step.value}
className="modern-sk-slider__step"
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
>
<div className="modern-sk-slider__step-tick" />
{step.label != null && (
<span className="modern-sk-slider__step-label">{step.label}</span>
)}
</div>
))}
</div>
)}
</RSlider.Root>
);
};