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