Slider component

Implements headless Slider with Root, Track, Range, and Thumb parts. Supports controlled/uncontrolled value, keyboard navigation (Arrow, Page, Home, End), clamping, step, orientation, and disabled state. 10 tests added, all 152 suite tests pass.
This commit is contained in:
Mats Bosson 2026-03-29 08:30:31 +07:00
parent ddc5aa3d7f
commit 796ccab838
7 changed files with 353 additions and 0 deletions

View File

@ -0,0 +1,18 @@
import { useSliderContext } from "./slider-context";
import { SliderRange } from "./slider-range";
import { SliderRoot } from "./slider-root";
import { SliderThumb } from "./slider-thumb";
import { SliderTrack } from "./slider-track";
export const Slider = Object.assign(SliderRoot, {
Track: SliderTrack,
Range: SliderRange,
Thumb: SliderThumb,
useContext: useSliderContext,
});
export type { SliderRootProps } from "./slider-root";
export type { SliderTrackProps } from "./slider-track";
export type { SliderRangeProps } from "./slider-range";
export type { SliderThumbProps } from "./slider-thumb";
export type { SliderContextValue } from "./slider-context";

View File

@ -0,0 +1,31 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
/** Context shared between all Slider parts. */
export interface SliderContextValue {
value: Accessor<number>;
setValue: (v: number) => void;
min: Accessor<number>;
max: Accessor<number>;
step: Accessor<number>;
orientation: Accessor<"horizontal" | "vertical">;
disabled: Accessor<boolean>;
}
const SliderContext = createContext<SliderContextValue>();
/**
* Returns the Slider context. Throws if used outside <Slider>.
*/
export function useSliderContext(): SliderContextValue {
const ctx = useContext(SliderContext);
if (!ctx) {
throw new Error(
"[PettyUI] Slider parts must be used inside <Slider>.\n" +
" Fix: Wrap Slider.Track, Slider.Range, and Slider.Thumb inside <Slider>.",
);
}
return ctx;
}
export const SliderContextProvider = SliderContext.Provider;

View File

@ -0,0 +1,21 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useSliderContext } from "./slider-context";
/** Props for Slider.Range — same as div attributes. */
export type SliderRangeProps = JSX.HTMLAttributes<HTMLDivElement>;
/** The filled portion of the slider track representing the current value. */
export function SliderRange(props: SliderRangeProps): JSX.Element {
const [, rest] = splitProps(props, []);
const ctx = useSliderContext();
const percentage = () => ((ctx.value() - ctx.min()) / (ctx.max() - ctx.min())) * 100;
const style = () =>
ctx.orientation() === "horizontal"
? { width: `${percentage()}%` }
: { height: `${percentage()}%` };
return <div style={style()} data-orientation={ctx.orientation()} {...rest} />;
}

View File

@ -0,0 +1,77 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import { SliderContextProvider, type SliderContextValue } from "./slider-context";
/** Props for the Slider root component. */
export interface SliderRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Controlled value. */
value?: number;
/** Initial value (uncontrolled). Defaults to min. */
defaultValue?: number;
/** Called when the value changes. */
onValueChange?: ((value: number) => void) | undefined;
/** @default 0 */
min?: number;
/** @default 100 */
max?: number;
/** @default 1 */
step?: number;
/** @default "horizontal" */
orientation?: "horizontal" | "vertical";
disabled?: boolean;
children: JSX.Element;
}
/**
* Root container for the Slider component. Manages the current value.
*/
export function SliderRoot(props: SliderRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"value",
"defaultValue",
"onValueChange",
"min",
"max",
"step",
"orientation",
"disabled",
"children",
]);
const min = () => local.min ?? 0;
const max = () => local.max ?? 100;
const step = () => local.step ?? 1;
const [sliderValue, setSliderValue] = createControllableSignal<number>({
value: () => local.value,
defaultValue: () => local.defaultValue ?? min(),
onChange: (v) => {
if (v !== undefined) local.onValueChange?.(v);
},
});
const clamp = (v: number) => Math.min(max(), Math.max(min(), v));
const ctx: SliderContextValue = {
value: sliderValue,
setValue: (v) => setSliderValue(clamp(v)),
min,
max,
step,
orientation: () => local.orientation ?? "horizontal",
disabled: () => local.disabled ?? false,
};
return (
<SliderContextProvider value={ctx}>
<div
data-orientation={local.orientation ?? "horizontal"}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</SliderContextProvider>
);
}

View File

@ -0,0 +1,67 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useSliderContext } from "./slider-context";
/** Props for Slider.Thumb. */
export interface SliderThumbProps extends JSX.HTMLAttributes<HTMLSpanElement> {
/** Accessible label for the thumb. */
"aria-label"?: string;
}
/** The draggable thumb element. Handles keyboard navigation. */
export function SliderThumb(props: SliderThumbProps): JSX.Element {
const [local, rest] = splitProps(props, ["onKeyDown"]);
const ctx = useSliderContext();
const handleKeyDown: JSX.EventHandler<HTMLSpanElement, KeyboardEvent> = (e) => {
if (typeof local.onKeyDown === "function") local.onKeyDown(e);
if (ctx.disabled()) return;
const current = ctx.value();
const s = ctx.step();
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
e.preventDefault();
ctx.setValue(current + s);
break;
case "ArrowLeft":
case "ArrowDown":
e.preventDefault();
ctx.setValue(current - s);
break;
case "PageUp":
e.preventDefault();
ctx.setValue(current + s * 10);
break;
case "PageDown":
e.preventDefault();
ctx.setValue(current - s * 10);
break;
case "Home":
e.preventDefault();
ctx.setValue(ctx.min());
break;
case "End":
e.preventDefault();
ctx.setValue(ctx.max());
break;
}
};
return (
<span
role="slider"
aria-valuemin={ctx.min()}
aria-valuemax={ctx.max()}
aria-valuenow={ctx.value()}
aria-orientation={ctx.orientation()}
aria-disabled={ctx.disabled() || undefined}
data-disabled={ctx.disabled() || undefined}
tabIndex={ctx.disabled() ? -1 : 0}
onKeyDown={handleKeyDown}
{...rest}
/>
);
}

View File

@ -0,0 +1,19 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useSliderContext } from "./slider-context";
/** Props for Slider.Track. */
export interface SliderTrackProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** The track element that contains Range and Thumb. */
export function SliderTrack(props: SliderTrackProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useSliderContext();
return (
<div data-orientation={ctx.orientation()} data-disabled={ctx.disabled() || undefined} {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,120 @@
// packages/core/tests/components/slider/slider.test.tsx
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Slider } from "../../../src/components/slider/index";
describe("Slider", () => {
it("thumb has role=slider", () => {
render(() => (
<Slider defaultValue={50}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
expect(screen.getByRole("slider")).toBeTruthy();
});
it("thumb has correct aria attributes", () => {
render(() => (
<Slider defaultValue={50} min={0} max={100}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
expect(thumb.getAttribute("aria-valuenow")).toBe("50");
expect(thumb.getAttribute("aria-valuemin")).toBe("0");
expect(thumb.getAttribute("aria-valuemax")).toBe("100");
});
it("ArrowRight increases value by step", () => {
render(() => (
<Slider defaultValue={50} step={5}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "ArrowRight" });
expect(thumb.getAttribute("aria-valuenow")).toBe("55");
});
it("ArrowLeft decreases value by step", () => {
render(() => (
<Slider defaultValue={50} step={5}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "ArrowLeft" });
expect(thumb.getAttribute("aria-valuenow")).toBe("45");
});
it("Home sets value to min", () => {
render(() => (
<Slider defaultValue={50} min={10} max={100}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "Home" });
expect(thumb.getAttribute("aria-valuenow")).toBe("10");
});
it("End sets value to max", () => {
render(() => (
<Slider defaultValue={50} min={0} max={100}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "End" });
expect(thumb.getAttribute("aria-valuenow")).toBe("100");
});
it("value is clamped at max", () => {
render(() => (
<Slider defaultValue={95} max={100} step={10}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "ArrowRight" });
expect(thumb.getAttribute("aria-valuenow")).toBe("100");
});
it("value is clamped at min", () => {
render(() => (
<Slider defaultValue={5} min={0} step={10}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "ArrowLeft" });
expect(thumb.getAttribute("aria-valuenow")).toBe("0");
});
it("controlled value", () => {
render(() => (
<Slider value={30} onValueChange={() => {}}>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
expect(screen.getByRole("slider").getAttribute("aria-valuenow")).toBe("30");
});
it("disabled thumb does not respond to keyboard", () => {
render(() => (
<Slider defaultValue={50} disabled>
<Slider.Track><Slider.Range /><Slider.Thumb /></Slider.Track>
</Slider>
));
const thumb = screen.getByRole("slider");
thumb.focus();
fireEvent.keyDown(thumb, { key: "ArrowRight" });
expect(thumb.getAttribute("aria-valuenow")).toBe("50");
});
});