From 796ccab838df6e202d6cc84778f93e2f6a4d8934 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 08:30:31 +0700 Subject: [PATCH] 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. --- packages/core/src/components/slider/index.ts | 18 +++ .../src/components/slider/slider-context.ts | 31 +++++ .../src/components/slider/slider-range.tsx | 21 +++ .../src/components/slider/slider-root.tsx | 77 +++++++++++ .../src/components/slider/slider-thumb.tsx | 67 ++++++++++ .../src/components/slider/slider-track.tsx | 19 +++ .../tests/components/slider/slider.test.tsx | 120 ++++++++++++++++++ 7 files changed, 353 insertions(+) create mode 100644 packages/core/src/components/slider/index.ts create mode 100644 packages/core/src/components/slider/slider-context.ts create mode 100644 packages/core/src/components/slider/slider-range.tsx create mode 100644 packages/core/src/components/slider/slider-root.tsx create mode 100644 packages/core/src/components/slider/slider-thumb.tsx create mode 100644 packages/core/src/components/slider/slider-track.tsx create mode 100644 packages/core/tests/components/slider/slider.test.tsx diff --git a/packages/core/src/components/slider/index.ts b/packages/core/src/components/slider/index.ts new file mode 100644 index 0000000..67c8982 --- /dev/null +++ b/packages/core/src/components/slider/index.ts @@ -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"; diff --git a/packages/core/src/components/slider/slider-context.ts b/packages/core/src/components/slider/slider-context.ts new file mode 100644 index 0000000..fc7e45d --- /dev/null +++ b/packages/core/src/components/slider/slider-context.ts @@ -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; + setValue: (v: number) => void; + min: Accessor; + max: Accessor; + step: Accessor; + orientation: Accessor<"horizontal" | "vertical">; + disabled: Accessor; +} + +const SliderContext = createContext(); + +/** + * Returns the Slider context. Throws if used outside . + */ +export function useSliderContext(): SliderContextValue { + const ctx = useContext(SliderContext); + if (!ctx) { + throw new Error( + "[PettyUI] Slider parts must be used inside .\n" + + " Fix: Wrap Slider.Track, Slider.Range, and Slider.Thumb inside .", + ); + } + return ctx; +} + +export const SliderContextProvider = SliderContext.Provider; diff --git a/packages/core/src/components/slider/slider-range.tsx b/packages/core/src/components/slider/slider-range.tsx new file mode 100644 index 0000000..c618484 --- /dev/null +++ b/packages/core/src/components/slider/slider-range.tsx @@ -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; + +/** 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
; +} diff --git a/packages/core/src/components/slider/slider-root.tsx b/packages/core/src/components/slider/slider-root.tsx new file mode 100644 index 0000000..d9e838c --- /dev/null +++ b/packages/core/src/components/slider/slider-root.tsx @@ -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 { + /** 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({ + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/slider/slider-thumb.tsx b/packages/core/src/components/slider/slider-thumb.tsx new file mode 100644 index 0000000..18b1eef --- /dev/null +++ b/packages/core/src/components/slider/slider-thumb.tsx @@ -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 { + /** 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 = (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 ( + + ); +} diff --git a/packages/core/src/components/slider/slider-track.tsx b/packages/core/src/components/slider/slider-track.tsx new file mode 100644 index 0000000..c7d7265 --- /dev/null +++ b/packages/core/src/components/slider/slider-track.tsx @@ -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 { + 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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/tests/components/slider/slider.test.tsx b/packages/core/tests/components/slider/slider.test.tsx new file mode 100644 index 0000000..650e678 --- /dev/null +++ b/packages/core/tests/components/slider/slider.test.tsx @@ -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(() => ( + + + + )); + expect(screen.getByRole("slider")).toBeTruthy(); + }); + + it("thumb has correct aria attributes", () => { + render(() => ( + + + + )); + 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(() => ( + + + + )); + 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(() => ( + + + + )); + 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(() => ( + + + + )); + 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(() => ( + + + + )); + 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(() => ( + + + + )); + 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(() => ( + + + + )); + const thumb = screen.getByRole("slider"); + thumb.focus(); + fireEvent.keyDown(thumb, { key: "ArrowLeft" }); + expect(thumb.getAttribute("aria-valuenow")).toBe("0"); + }); + + it("controlled value", () => { + render(() => ( + {}}> + + + )); + expect(screen.getByRole("slider").getAttribute("aria-valuenow")).toBe("30"); + }); + + it("disabled thumb does not respond to keyboard", () => { + render(() => ( + + + + )); + const thumb = screen.getByRole("slider"); + thumb.focus(); + fireEvent.keyDown(thumb, { key: "ArrowRight" }); + expect(thumb.getAttribute("aria-valuenow")).toBe("50"); + }); +});