diff --git a/.changeset/mighty-timers-cough.md b/.changeset/mighty-timers-cough.md new file mode 100644 index 00000000..0465ff11 --- /dev/null +++ b/.changeset/mighty-timers-cough.md @@ -0,0 +1,5 @@ +--- +"@kobalte/core": patch +--- + +added `Slider` component diff --git a/apps/docs/src/VERSIONS.ts b/apps/docs/src/VERSIONS.ts index 43c3db55..86f3ef15 100644 --- a/apps/docs/src/VERSIONS.ts +++ b/apps/docs/src/VERSIONS.ts @@ -14,4 +14,4 @@ export const CORE_VERSIONS = [ export const LATEST_CORE_CHANGELOG_URL = `/docs/changelog/${CORE_VERSIONS[0].replaceAll(".", "-")}`; -export const LATEST_CORE_VERSION_NAME = "v0.11.1"; +export const LATEST_CORE_VERSION_NAME = "v0.11.2"; diff --git a/apps/docs/src/examples/slider.module.css b/apps/docs/src/examples/slider.module.css new file mode 100644 index 00000000..a28a6bbc --- /dev/null +++ b/apps/docs/src/examples/slider.module.css @@ -0,0 +1,71 @@ +.SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; +} + +.SliderRoot[data-orientation="vertical"] { + height: 200px; +} + +.SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; +} + +.SliderTrack[data-orientation="vertical"] { + width: 8px; + height: 100%; +} + +.SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; +} + +.SliderRange[data-orientation="vertical"] { + width: 100%; + height: unset; +} + +.SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; +} + +.SliderThumb[data-orientation="vertical"] { + left: -4px; + top: unset; +} + +.SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; +} + +.SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; +} + +.SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; +} + +[data-kb-theme="dark"] .SliderTrack { + background-color: hsl(240 5% 26%); +} diff --git a/apps/docs/src/examples/slider.tsx b/apps/docs/src/examples/slider.tsx new file mode 100644 index 00000000..4feb80a0 --- /dev/null +++ b/apps/docs/src/examples/slider.tsx @@ -0,0 +1,164 @@ +import { Slider } from "@kobalte/core"; +import { createSignal } from "solid-js"; +import style from "./slider.module.css"; + +export function BasicExample() { + return ( + +
+ Label + +
+ + + + + + +
+ ); +} + +export function MultipleThumbsExample() { + return ( + +
+ Label + +
+ + + + + + + + + +
+ ); +} + +export function StepExample() { + return ( +
+ +
+ Step size 8 + +
+ + + + + + +
+ +
+ Step size 10 + +
+ + + + + + +
+ +
+ Step size 20 + +
+ + + + + + +
+
+ ); +} + +export function MinStepsBetweenExample() { + return ( + +
+ Label + +
+ + + + + + + + + +
+ ); +} + +export function VerticalSliderExample() { + return ( + +
+ Label + +
+ + + + + + +
+ ); +} + +export function CustomValueLabelExample() { + return ( + `$${params.values[0]} - $${params.values[1]}`} + > +
+ Money + +
+ + + + + + + + + +
+ ); +} + +export function ControlledExample() { + const [values, setValues] = createSignal([40]); + return ( + +
+ Label + +
+ + + + + + +
+ ); +} diff --git a/apps/docs/src/routes/docs/changelog/0-11-x.mdx b/apps/docs/src/routes/docs/changelog/0-11-x.mdx index 61a501d1..3e7ca2c1 100644 --- a/apps/docs/src/routes/docs/changelog/0-11-x.mdx +++ b/apps/docs/src/routes/docs/changelog/0-11-x.mdx @@ -1,5 +1,15 @@ # v0.11.x +## v0.11.2 (October 21, 2023) + +**New features** + +- Added `Slider` component. + +**Bug fixes** + +- [#278](https://github.com/kobaltedev/kobalte/pull/278) + ## v0.11.1 (October 20, 2023) **New features** diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index 40eb8ee0..efab4157 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -67,7 +67,6 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Combobox", href: "/docs/core/components/combobox", - status: "updated", }, { title: "Context Menu", @@ -122,6 +121,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ href: "/docs/core/components/skeleton", status: "new", }, + { + title: "Slider", + href: "/docs/core/components/slider", + status: "new", + }, { title: "Switch", href: "/docs/core/components/switch", diff --git a/apps/docs/src/routes/docs/core/components/slider.mdx b/apps/docs/src/routes/docs/core/components/slider.mdx new file mode 100644 index 00000000..98f1e899 --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/slider.mdx @@ -0,0 +1,813 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; +import { + BasicExample, + MultipleThumbsExample, + StepExample, + MinStepsBetweenExample, + VerticalSliderExample, + CustomValueLabelExample, + ControlledExample, +} from "../../../../examples/slider"; + +# Slider + +An input where the user selects a value from within a given range. + +## Import + +```ts +import { Slider } from "@kobalte/core"; +``` + +## Features + +- Follow the [WAI ARIA Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/) design pattern. +- Can be controlled or uncontrolled. +- Support for multiple thumbs. +- Support a minimum step between thumbs. +- Support click or touch on track to change value. +- Support right or left direction. +- Support for custom value label. + +## Anatomy + +The slider consists of: + +- **Slider.Root:** The root container for the slider. +- **Slider.Track:** The component that visually represents the slider track. +- **Slider.Fill:** The component that visually represents the slider value. +- **Slider.Thumb:** The thumb that is used to visually indicate a value in the slider. +- **Slider.Input:** The native html input that is visually hidden in the slider thumb. +- **Slider.Label:** The label that gives the user information on the slider. +- **Slider.ValueLabel:** The accessible label text representing the current value in a human-readable format. + +```tsx + + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + +
+ Label + +
+ + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +## Usage + +### Multiple Thumbs + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + +
+ Label + +
+ + + + + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +### Modify step size + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + <> + +
+ Step size 8 + +
+ + + + + + + + + +
+ +
+ Step size 10 + +
+ + + + + + + + + +
+ +
+ Step size 20 + +
+ + + + + + + + + +
+ + ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +### Steps between thumbs + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + +
+ Label + +
+ + + + + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +### Vertical Slider + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + +
+ Label + +
+ + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + height: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + width: 8px; + height: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + width: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + left: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +### Custom Value Label + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + return ( + `$${params.values[0]} - $${params.values[1]}`} + > +
+ Money + +
+ + + + + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+ +### Controlled Value + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { createSignal } from 'solid-js' + import { Slider } from "@kobalte/core"; + import "./style.css"; + + function App() { + const [values, setValues] = createSignal([40]) + + return ( + +
+ Label + +
+ + + + + + +
+ ); + } + ``` + +
+ + ```css + .SliderRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + width: 200px; + } + + .SliderTrack { + background-color: hsl(240 6% 90%); + position: relative; + border-radius: 9999px; + height: 8px; + width: 100%; + } + + .SliderRange { + position: absolute; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + height: 100%; + } + + .SliderThumb { + display: block; + width: 16px; + height: 16px; + background-color: hsl(200 98% 39%); + border-radius: 9999px; + top: -4px; + } + + .SliderThumb:hover { + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderThumb:focus { + outline: none; + box-shadow: 0 0 0 5px #2a91fe98; + } + + .SliderLabel { + width: 100%; + display: flex; + justify-content: space-between; + } + ``` + + +{/* */} +
+## API Reference + +### Slider.Root + +| Prop | Description | +| :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `number[]`
The controlled values of the slider. Must be used in conjunction with `onChange`. | +| defaultValue | `number[]`
The value of the slider when initially rendered. Use when you do not need to control the state of the slider. | +| onChange | `(value: number[]) => void`
Event handler called when the value changes. | +| onChangeEnd | `(value: number[]) => void`
Event handler called when the value changes at the end of an interaction. | +| inverted | `boolean`
Whether the slider is visually inverted. Defaults to false. | +| minValue | `number`
The minimum slider value. Defaults to 0 | +| maxValue | `number`
The maximum slider value. Defaults to 100 | +| step | `number`
The stepping interval. Defaults to 1 | +| minStepsBetweenThumbs | `number`
The minimum permitted steps between thumbs. Defaults to 0 | +| getValueLabel | `(params: GetValueLabelParams) => string`
A function to get the accessible label text representing the current value in a human-readable format. If not provided, the value label will be read as a percentage of the max value. | +| orientation | `'horizontal' \| 'vertical'`
The orientation of the slider. | +| name | `string`
The name of the slider, used when submitting an HTML form. | +| validationState | `'valid' \| 'invalid'`
Whether the slider should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must check a radio group item before the owning form can be submitted. | +| disabled | `boolean`
Whether the radio group is disabled. | +| readOnly | `boolean`
Whether the radio group items can be selected but not changed by the user. | + +| Data attribute | Description | +| :---------------------------- | :--------------------------------------------------------------------------------- | +| data-orientation='horizontal' | Present when the slider has horizontal orientation. | +| data-orientation='vertical' | Present when the slider has vertical orientation. | +| data-valid | Present when the slider is valid according to the validation rules. | +| data-invalid | Present when the slider is invalid according to the validation rules. | +| data-required | Present when the user must slider an item before the owning form can be submitted. | +| data-disabled | Present when the slider is disabled. | +| data-readonly | Present when the slider is read only. | + +`Slider.ValueLabel`, `Slider.Fill`, `Slider.Input`, `Slider.Thumb` and `Slider.Track` share the same data-attributes. + +## Rendered elements + +| Component | Default rendered element | +| :------------------ | :----------------------- | +| `Slider.Root` | `div` | +| `Slider.Track` | `div` | +| `Slider.Fill` | `div` | +| `Slider.Thumb` | `span` | +| `Slider.Input` | `input` | +| `Slider.ValueLabel` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-------------------- | :-------------------------------------------------------------------- | +| PageUp | Increases the value of the focused thumb by a larger `step`. | +| PageDown | Decreases the value of the focused thumb by a larger `step`. | +| ArrowDown | Decreases the value of the focused thumb by the `step` amount. | +| ArrowUp | Increases the value of the focused thumb by the `step` amount. | +| ArrowRight | Increments/decrements by the `step` value depending on `orientation`. | +| ArrowLeft | Increments/decrements by the `step` value depending on `orientation`. | +| Home | Sets the value of the first thumb to the minimum value. | +| End | Sets the value of the last thumb to the maximum value. | diff --git a/packages/core/src/slider/slider-context.tsx b/packages/core/src/slider/slider-context.tsx index 01c11f1f..9876d220 100644 --- a/packages/core/src/slider/slider-context.tsx +++ b/packages/core/src/slider/slider-context.tsx @@ -16,7 +16,7 @@ export interface SliderContextValue { state: SliderState; thumbs: Accessor; setThumbs: Setter; - onSlideStart: ((value: number) => void) | undefined; + onSlideStart: ((index: number, value: number) => void) | undefined; onSlideMove: ((deltas: { deltaX: number; deltaY: number }) => void) | undefined; onSlideEnd: (() => void) | undefined; onStepKeyDown: (event: KeyboardEvent, index: number) => void; diff --git a/packages/core/src/slider/slider-root.tsx b/packages/core/src/slider/slider-root.tsx index adfa5287..c99c3dc3 100644 --- a/packages/core/src/slider/slider-root.tsx +++ b/packages/core/src/slider/slider-root.tsx @@ -205,14 +205,11 @@ export function SliderRoot(props: SliderRootProps) { const [trackRef, setTrackRef] = createSignal(); let currentPosition: number | null = null; - const onSlideStart = (value: number) => { - const closestIndex = getClosestValueIndex(state.values(), value); - if (closestIndex >= 0) { - state.setFocusedThumb(closestIndex); - state.setThumbDragging(closestIndex, true); - state.setThumbValue(closestIndex, value); - currentPosition = null; - } + const onSlideStart = (index: number, value: number) => { + state.setFocusedThumb(index); + state.setThumbDragging(index, true); + state.setThumbValue(index, value); + currentPosition = null; }; const onSlideMove = ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { diff --git a/packages/core/src/slider/slider-thumb.tsx b/packages/core/src/slider/slider-thumb.tsx index d23c8c2d..f62d3d1e 100644 --- a/packages/core/src/slider/slider-thumb.tsx +++ b/packages/core/src/slider/slider-thumb.tsx @@ -13,7 +13,15 @@ */ import { callHandler, mergeDefaultProps, mergeRefs, OverrideComponentProps } from "@kobalte/utils"; -import { Accessor, createContext, JSX, onMount, splitProps, useContext } from "solid-js"; +import { + Accessor, + createContext, + createUniqueId, + JSX, + onMount, + splitProps, + useContext, +} from "solid-js"; import { createFormControlField, FORM_CONTROL_FIELD_PROP_NAMES } from "../form-control"; import { AsChildProp, Polymorphic } from "../polymorphic"; @@ -33,7 +41,7 @@ export function SliderThumb(props: SliderThumbProps) { props = mergeDefaultProps( { - id: context.generateId("thumb"), + id: context.generateId(`thumb-${createUniqueId()}`), }, props, ); @@ -101,18 +109,20 @@ export function SliderThumb(props: SliderThumbProps) { const target = e.currentTarget as HTMLElement; - target.setPointerCapture(e.pointerId); e.preventDefault(); + e.stopPropagation(); + target.setPointerCapture(e.pointerId); target.focus(); startPosition = context.state.orientation() === "horizontal" ? e.clientX : e.clientY; - if (value()) { - context.onSlideStart?.(value()!); + if (value() !== undefined) { + context.onSlideStart?.(index(), value()!); } }; const onPointerMove: JSX.EventHandlerUnion = e => { + e.stopPropagation(); callHandler(e, local.onPointerMove); const target = e.currentTarget as HTMLElement; @@ -129,6 +139,7 @@ export function SliderThumb(props: SliderThumbProps) { }; const onPointerUp: JSX.EventHandlerUnion = e => { + e.stopPropagation(); callHandler(e, local.onPointerUp); const target = e.currentTarget as HTMLElement; diff --git a/packages/core/src/slider/slider-track.tsx b/packages/core/src/slider/slider-track.tsx index f3b39920..0197da44 100644 --- a/packages/core/src/slider/slider-track.tsx +++ b/packages/core/src/slider/slider-track.tsx @@ -3,7 +3,7 @@ import { createSignal, JSX, splitProps } from "solid-js"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useSliderContext } from "./slider-context"; -import { linearScale } from "./utils"; +import { getClosestValueIndex, linearScale } from "./utils"; export interface SliderTrackProps extends OverrideComponentProps<"div", AsChildProp> {} @@ -58,7 +58,8 @@ export function SliderTrack(props: SliderTrackProps) { context.state.orientation() === "horizontal" ? e.clientX : e.clientY, ); startPosition = context.state.orientation() === "horizontal" ? e.clientX : e.clientY; - context.onSlideStart?.(value); + const closestIndex = getClosestValueIndex(context.state.values(), value); + context.onSlideStart?.(closestIndex, value); }; const onPointerMove: JSX.EventHandlerUnion = e => {