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:
parent
ddc5aa3d7f
commit
796ccab838
18
packages/core/src/components/slider/index.ts
Normal file
18
packages/core/src/components/slider/index.ts
Normal 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";
|
||||||
31
packages/core/src/components/slider/slider-context.ts
Normal file
31
packages/core/src/components/slider/slider-context.ts
Normal 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;
|
||||||
21
packages/core/src/components/slider/slider-range.tsx
Normal file
21
packages/core/src/components/slider/slider-range.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
77
packages/core/src/components/slider/slider-root.tsx
Normal file
77
packages/core/src/components/slider/slider-root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
packages/core/src/components/slider/slider-thumb.tsx
Normal file
67
packages/core/src/components/slider/slider-thumb.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
packages/core/src/components/slider/slider-track.tsx
Normal file
19
packages/core/src/components/slider/slider-track.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
packages/core/tests/components/slider/slider.test.tsx
Normal file
120
packages/core/tests/components/slider/slider.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user