visuals, fixes & storybook upd
This commit is contained in:
+109
-43
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ export const Spinner = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
|
||||
const gid = `modern-sk-groove-${useId()}`;
|
||||
const uid = useId();
|
||||
const grooveId = `modern-sk-groove-${uid}`;
|
||||
const glowId = `modern-sk-glow-${uid}`;
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
@@ -16,29 +18,56 @@ export const Spinner = ({
|
||||
>
|
||||
<svg viewBox="0 0 36 36" fill="none">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gid}
|
||||
x1="18"
|
||||
y1="4"
|
||||
x2="18"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="var(--spin-groove-1)" />
|
||||
<stop offset="1" stopColor="var(--spin-groove-2)" />
|
||||
</linearGradient>
|
||||
{/* Carved channel: flat ring sunk by a top inner shadow — like the switch well. */}
|
||||
<filter id={grooveId} x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feComponentTransfer in="SourceAlpha" result="inv">
|
||||
<feFuncA type="table" tableValues="1 0" />
|
||||
</feComponentTransfer>
|
||||
<feGaussianBlur in="inv" stdDeviation="1" result="blur" />
|
||||
<feOffset in="blur" dy="1" result="off" />
|
||||
<feFlood floodColor="#000" floodOpacity="0.7" />
|
||||
<feComposite in2="off" operator="in" />
|
||||
<feComposite in2="SourceAlpha" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
<feMergeNode in="shadow" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Soft round glow — generous region so it never clips to a square. */}
|
||||
<filter id={glowId} x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="1.6" />
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
|
||||
<circle
|
||||
className="modern-sk-spinner__arc"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
stroke="var(--lime)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="22 88"
|
||||
stroke="var(--spin-track)"
|
||||
strokeWidth="5"
|
||||
filter={`url(#${grooveId})`}
|
||||
/>
|
||||
<g className="modern-sk-spinner__arc">
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
stroke="var(--lime)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="22 88"
|
||||
opacity="0.8"
|
||||
filter={`url(#${glowId})`}
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
stroke="var(--lime)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="22 88"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user