diff --git a/.changeset/slider-component.md b/.changeset/slider-component.md new file mode 100644 index 000000000..ded3c21b4 --- /dev/null +++ b/.changeset/slider-component.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add `Slider` component for picking a number, or a range between two numbers, by dragging a thumb along a track. Built on Base UI's Slider: pass an array to `value` or `defaultValue` for a range. Shows each thumb's value in a badge below the track and the minimum and maximum at either end, formats them with `format`, and supports `sm` and `base` sizes. diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 2c6268cc2..6d1266c01 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -75,6 +75,7 @@ const componentItems: NavItem[] = [ { label: "Sensitive Input", href: "/components/sensitive-input" }, { label: "Sidebar", href: "/components/sidebar" }, { label: "Skeleton Line", href: "/components/skeleton-line" }, + { label: "Slider", href: "/components/slider" }, { label: "Switch", href: "/components/switch" }, { label: "Table", href: "/components/table" }, { label: "Table of Contents", href: "/components/table-of-contents" }, diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index 68f928ca6..4b7c8bf68 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -30,6 +30,7 @@ import { Select, SensitiveInput, SkeletonLine, + Slider, Switch, Table, TableOfContents, @@ -91,6 +92,7 @@ const componentRoutes: Record = { select: "/components/select", "sensitive-input": "/components/sensitive-input", "skeleton-line": "/components/skeleton-line", + slider: "/components/slider", switch: "/components/switch", table: "/components/table", "table-of-contents": "/components/table-of-contents", @@ -558,6 +560,17 @@ export function HomeGrid() { ), }, + { + name: "Slider", + id: "slider", + Component: ( + (index === 0 ? "Minimum" : "Maximum")} + className="w-48" + /> + ), + }, { name: "DatePicker", id: "date-picker", diff --git a/packages/kumo-docs-astro/src/components/demos/SliderDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SliderDemo.tsx new file mode 100644 index 000000000..fa61afa24 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/SliderDemo.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Slider } from "@cloudflare/kumo"; + +export function SliderBasicDemo() { + return ( + + ); +} + +export function SliderRangeDemo() { + return ( + (index === 0 ? "Minimum price" : "Maximum price")} + className="w-72" + /> + ); +} + +export function SliderStepDemo() { + return ( + + ); +} + +export function SliderFormatDemo() { + return ( + + ); +} + +export function SliderSizesDemo() { + return ( +
+ + +
+ ); +} + +export function SliderControlledDemo() { + const [value, setValue] = useState(50); + + return ( +
+ setValue(next)} + /> +

Brightness: {value}%

+
+ ); +} + +export function SliderDisabledDemo() { + return ( + + ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/slider.mdx b/packages/kumo-docs-astro/src/pages/components/slider.mdx new file mode 100644 index 000000000..13444948f --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/slider.mdx @@ -0,0 +1,142 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "Slider" +description: "An input for picking a number, or a range between two numbers, by dragging a thumb along a track." +sourceFile: "components/slider" +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + SliderBasicDemo, + SliderRangeDemo, + SliderStepDemo, + SliderFormatDemo, + SliderSizesDemo, + SliderControlledDemo, + SliderDisabledDemo, +} from "~/components/demos/SliderDemo"; + +{/* Hero Demo */} + + + + + + + +{/* Installation */} + + + +## Installation + +### Barrel + +```tsx +import { Slider } from "@cloudflare/kumo"; +``` + +### Granular + +```tsx +import { Slider } from "@cloudflare/kumo/components/slider"; +``` + + + +{/* Usage */} + + + +## Usage + +```tsx +import { Slider } from "@cloudflare/kumo"; + +export default function Example() { + return ; +} +``` + +`Slider` is built on [Base UI's Slider](https://base-ui.com/react/components/slider). Props such as `value`, `defaultValue`, `min`, `max`, `step`, `format`, and `onValueChange` are passed through to it. + + + +{/* Examples */} + + + +## Examples + +### Basic Slider + +

A single thumb, with the value in a badge below it and the minimum and maximum at either end.

+ + + + +### Range + +

Pass an array to `value` or `defaultValue` to render one thumb per value. Use `getAriaLabel` to give each thumb its own accessible name.

+ + + + +### Custom Range and Step + +

Set `min`, `max`, and `step` to constrain the values.

+ + + + +### Formatted Values + +

`format` takes `Intl.NumberFormat` options and applies to the badges, the range labels, and the value announced to assistive technology.

+ + + + +### Sizes + +

Use `size="sm"` for dense layouts.

+ + + + +### Controlled + +

Pass `value` and `onValueChange` to control the slider.

+ + + + +### Disabled + + + + + +
+ +{/* Accessibility */} + + + +## Accessibility + +- Each thumb is a native range input, so arrow keys, Page Up/Page Down, and Home/End work by default. +- `label` names the slider and its thumb. Without a visible label, pass `getAriaLabel`. +- The value badges and range labels are hidden from assistive technology, because each thumb already announces its formatted value. + + + +{/* API Reference */} + + + +## API Reference + + + diff --git a/packages/kumo/package.json b/packages/kumo/package.json index 1c3b890ac..37545af05 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -208,6 +208,10 @@ "types": "./dist/components/sidebar.d.ts", "import": "./dist/components/sidebar.js" }, + "./components/slider": { + "types": "./dist/components/slider.d.ts", + "import": "./dist/components/slider.js" + }, "./components/surface": { "types": "./dist/components/surface.d.ts", "import": "./dist/components/surface.js" diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts index 59eb7d4f5..d5e4294d0 100644 --- a/packages/kumo/scripts/component-registry/discovery.ts +++ b/packages/kumo/scripts/component-registry/discovery.ts @@ -56,6 +56,7 @@ export const CATEGORY_MAP: Record = { "input-group": "Input", radio: "Input", select: "Input", + slider: "Input", switch: "Input", // Layout grid: "Layout", diff --git a/packages/kumo/scripts/component-registry/metadata.ts b/packages/kumo/scripts/component-registry/metadata.ts index 904c83330..f72204c59 100644 --- a/packages/kumo/scripts/component-registry/metadata.ts +++ b/packages/kumo/scripts/component-registry/metadata.ts @@ -289,6 +289,44 @@ export const ADDITIONAL_COMPONENT_PROPS: Record< description: "Minimum value of the meter (default: 0)", }, }, + Slider: { + value: { + type: "number | readonly number[]", + description: + "Controlled value. Pass an array to render one thumb per value (a range).", + }, + defaultValue: { + type: "number | readonly number[]", + description: + "Uncontrolled initial value. Pass an array to render one thumb per value (a range).", + }, + onValueChange: { + type: "(value: number | readonly number[], eventDetails: SliderRoot.ChangeEventDetails) => void", + description: + "Called when the value changes while dragging or from the keyboard", + }, + min: { + type: "number", + description: "Minimum value of the slider (default: 0)", + }, + max: { + type: "number", + description: "Maximum value of the slider (default: 100)", + }, + step: { + type: "number", + description: "Amount the value changes per step (default: 1)", + }, + format: { + type: "Intl.NumberFormatOptions", + description: + "Formats the value badges, range labels, and the value announced to assistive technology", + }, + disabled: { + type: "boolean", + description: "Whether the slider is disabled", + }, + }, Tooltip: { content: { type: "ReactNode", diff --git a/packages/kumo/src/components/slider/index.ts b/packages/kumo/src/components/slider/index.ts new file mode 100644 index 000000000..b6d97a997 --- /dev/null +++ b/packages/kumo/src/components/slider/index.ts @@ -0,0 +1,9 @@ +export { + Slider, + sliderVariants, + KUMO_SLIDER_VARIANTS, + KUMO_SLIDER_DEFAULT_VARIANTS, + type SliderProps, + type KumoSliderSize, + type KumoSliderVariantsProps, +} from "./slider"; diff --git a/packages/kumo/src/components/slider/slider.test.tsx b/packages/kumo/src/components/slider/slider.test.tsx new file mode 100644 index 000000000..9cb65b67f --- /dev/null +++ b/packages/kumo/src/components/slider/slider.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from "vite-plus/test"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Slider } from "./slider"; + +describe("Slider", () => { + it("should have correct display name", () => { + expect(Slider.displayName).toBe("Slider"); + }); + + it("labels the thumb with the visible label", () => { + render(); + + const thumb = screen.getByLabelText("Volume", { selector: "input" }); + expect(thumb.getAttribute("type")).toBe("range"); + expect(thumb.getAttribute("aria-valuenow")).toBe("40"); + }); + + it("renders one thumb per value for a range", () => { + render( + (index === 0 ? "Minimum" : "Maximum")} + />, + ); + + expect(screen.getByLabelText("Minimum").getAttribute("aria-valuenow")).toBe( + "25", + ); + expect(screen.getByLabelText("Maximum").getAttribute("aria-valuenow")).toBe( + "75", + ); + }); + + it("shows the value badges and the min and max labels", () => { + render(); + + expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText("0")).toBeTruthy(); + expect(screen.getByText("5")).toBeTruthy(); + }); + + it("formats values with the format option", () => { + render( + , + ); + + expect(screen.getByText("50%")).toBeTruthy(); + expect(screen.getByText("0%")).toBeTruthy(); + expect(screen.getByText("100%")).toBeTruthy(); + }); + + it("reports changes from the keyboard", () => { + const onValueChange = vi.fn(); + render( + , + ); + + fireEvent.keyDown(screen.getByLabelText("Volume", { selector: "input" }), { + key: "ArrowRight", + }); + + expect(onValueChange.mock.calls[0]?.[0]).toBe(41); + }); + + it("aligns the thumbs to the track edges by default", () => { + render(); + + const thumb = screen.getByLabelText("Volume", { selector: "input" }) + .parentElement as HTMLElement; + expect(thumb.style.getPropertyValue("--position")).not.toBe(""); + }); + + it("lets callers override the thumb alignment", () => { + render(); + + const thumb = screen.getByLabelText("Volume", { selector: "input" }) + .parentElement as HTMLElement; + expect(thumb.style.getPropertyValue("--position")).toBe(""); + }); + + it("stays horizontal even when an orientation is passed", () => { + const props = { orientation: "vertical" } as object; + render(); + + expect( + screen + .getByLabelText("Volume", { selector: "input" }) + .getAttribute("aria-orientation"), + ).toBe("horizontal"); + }); + + it("applies the size variant to the track", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".h-6")).toBeTruthy(); + }); +}); diff --git a/packages/kumo/src/components/slider/slider.tsx b/packages/kumo/src/components/slider/slider.tsx new file mode 100644 index 000000000..90417e958 --- /dev/null +++ b/packages/kumo/src/components/slider/slider.tsx @@ -0,0 +1,186 @@ +import { Slider as BaseSlider } from "@base-ui/react/slider"; +import { type ReactNode } from "react"; +import { cn } from "../../utils/cn"; + +/** Slider size definitions mapping sizes to their track heights. */ +export const KUMO_SLIDER_VARIANTS = { + size: { + sm: { + classes: "h-6 rounded-md", + description: "Compact slider for dense layouts", + }, + base: { + classes: "h-8 rounded-lg", + description: "Default slider size", + }, + }, +} as const; + +export const KUMO_SLIDER_DEFAULT_VARIANTS = { + size: "base", +} as const; + +export type KumoSliderSize = keyof typeof KUMO_SLIDER_VARIANTS.size; + +export interface KumoSliderVariantsProps { + /** + * Height of the slider track. + * - `"sm"` — Compact slider for dense layouts + * - `"base"` — Default slider size + * @default "base" + */ + size?: KumoSliderSize; +} + +export function sliderVariants({ + size = KUMO_SLIDER_DEFAULT_VARIANTS.size, +}: KumoSliderVariantsProps = {}) { + return cn( + "bg-kumo-recessed p-[3px] ring ring-kumo-line", + KUMO_SLIDER_VARIANTS.size[size].classes, + ); +} + +type SliderValue = number | readonly number[]; + +/** + * Slider component props. + * + * @example + * ```tsx + * + * + * ``` + */ +export interface SliderProps + extends + Omit, "children" | "orientation">, + KumoSliderVariantsProps { + /** Label displayed above the slider track. */ + label?: ReactNode; + /** + * Accessible name for each thumb. Use it when there is no visible `label`, + * or to tell the thumbs of a range slider apart. + */ + getAriaLabel?: (index: number) => string; +} + +function countThumbs(value: SliderValue | undefined) { + return Array.isArray(value) ? value.length : 1; +} + +/** + * Lets people pick a number, or a range between two numbers, by dragging a + * thumb along a track. Pass an array to `value` or `defaultValue` for a range. + * + * @example + * ```tsx + * + * ``` + */ +export function Slider({ + label, + size = KUMO_SLIDER_DEFAULT_VARIANTS.size, + getAriaLabel, + // Keeps the thumbs, and the grips inside them, within the track at either + // end of the range. + thumbAlignment = "edge", + className, + min = 0, + max = 100, + format, + locale, + value, + defaultValue, + ...props +}: SliderProps) { + const thumbCount = countThumbs(value ?? defaultValue); + const isRange = thumbCount > 1; + const formatter = new Intl.NumberFormat(locale, format); + const textSize = size === "sm" ? "text-xs" : "text-sm"; + // One step smaller than the track radius, so the inset corners stay concentric. + const innerRadius = size === "sm" ? "rounded" : "rounded-md"; + + return ( + + {label ? ( + + {label} + + ) : null} + + + + {Array.from({ length: thumbCount }, (_, index) => ( + + + + + {(formattedValues) => formattedValues[index]} + + + + ))} + + +
+ {formatter.format(min)} + {formatter.format(max)} +
+
+ ); +} + +Slider.displayName = "Slider"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index c9879f224..19d49eec1 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -373,6 +373,15 @@ export { type KumoLayerDialogSize, type KumoLayerDialogVerticalAlign, } from "./components/layer-dialog"; +export { + Slider, + sliderVariants, + KUMO_SLIDER_VARIANTS, + KUMO_SLIDER_DEFAULT_VARIANTS, + type SliderProps, + type KumoSliderSize, + type KumoSliderVariantsProps, +} from "./components/slider"; // PLOP_INJECT_EXPORT // Utils diff --git a/packages/kumo/vite.config.ts b/packages/kumo/vite.config.ts index c0a27fd3b..96d3bb4d1 100644 --- a/packages/kumo/vite.config.ts +++ b/packages/kumo/vite.config.ts @@ -131,6 +131,7 @@ const packEntries = { __dirname, "src/components/tag-input/index.ts", ), + "components/slider": resolve(__dirname, "src/components/slider/index.ts"), // PLOP_INJECT_COMPONENT_ENTRY // Utils entry point utils: resolve(__dirname, "src/utils/index.ts"),