Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion packages/dev/s2-docs/pages/react-aria/Slider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,48 @@ By default, slider values are percentages between 0 and 100. Use the `minValue`,
fillOffset: 75
}} />

## Marks

Render a `<SliderMark>` for each value you want to label along the track. Pressing a mark moves the nearest thumb to its value, so labels stay usable when they sit outside the track's bounds. Marks are not focusable — the thumb remains the keyboard interface — so keep their content short, or add `aria-hidden` where it would only repeat what the thumb already announces.

```tsx render
"use client";
import {Slider} from 'vanilla-starter/Slider';

<Slider
label="Brightness"
minValue={-100}
maxValue={100}
defaultValue={0}
/*- begin highlight -*/
marks={[-100, -50, 0, 50, 100]} />
{/*- end highlight -*/}
```

### Snapping

Marks alone do not change how a thumb moves. Use `snapPoints` to make a pointer snap to values as it passes them, while leaving the values in between reachable. `snapThreshold` controls how close the pointer must get, as a fraction of the track length.

Keyboard interactions are deliberately unaffected — arrow keys continue to move by `step` — so a snap point is a pointer affordance rather than a restriction on the value.

<VisualExample
component={VanillaSlider}
docs={vanillaDocs.exports.Slider}
links={vanillaDocs.links}
props={['snapThreshold', 'step']}
initialProps={{
label: 'Brightness',
minValue: -100,
maxValue: 100,
defaultValue: 0,
marks: [-100, -50, 0, 50, 100],
snapPoints: [-100, -50, 0, 50, 100]
}} />

Snap points do not have to be multiples of `step`. A value such as `0` on a slider that steps by 25 is only reachable with a pointer.

Marks and snap points cover the harder cases too. A macOS-style volume slider — ticks drawn across the track, a thumb that goes translucent while dragging, and a value readout that follows it — needs no other API: the thumb already exposes `isDragging` and `isHovered` as render props, and `SliderOutput` reads the live value. See the `SliderMarks` story for a worked example.

## Examples

<ExampleList tag="slider" pages={props.pages} />
Expand All @@ -107,12 +149,13 @@ By default, slider values are percentages between 0 and 100. Use the `minValue`,

<Anatomy />

```tsx links={{Slider: '#slider', SliderOutput: '#slideroutput', SliderTrack: '#slidertrack', SliderFill: '#sliderfill', SliderThumb: '#sliderthumb'}}
```tsx links={{Slider: '#slider', SliderOutput: '#slideroutput', SliderTrack: '#slidertrack', SliderFill: '#sliderfill', SliderMark: '#slidermark', SliderThumb: '#sliderthumb'}}
<Slider>
<Label />
<SliderOutput />
<SliderTrack>
<SliderFill />
<SliderMark />
<SliderThumb />
<SliderThumb>
<Label />
Expand All @@ -137,6 +180,10 @@ By default, slider values are percentages between 0 and 100. Use the `minValue`,

<PropTable component={docs.exports.SliderFill} links={docs.links} showDescription />

### SliderMark

<PropTable component={docs.exports.SliderMark} links={docs.links} showDescription />

### SliderThumb

<PropTable component={docs.exports.SliderThumb} links={docs.links} showDescription />
5 changes: 4 additions & 1 deletion packages/react-aria-components/exports/Slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
SliderTrack,
SliderThumb,
SliderFill,
SliderMark,
SliderContext,
SliderOutputContext,
SliderTrackContext,
Expand All @@ -35,7 +36,9 @@ export type {
SliderTrackRenderProps,
SliderThumbRenderProps,
SliderFillProps,
SliderFillRenderProps
SliderFillRenderProps,
SliderMarkProps,
SliderMarkRenderProps
} from '../src/Slider';
export type {SliderState} from 'react-stately/useSliderState';

Expand Down
5 changes: 4 additions & 1 deletion packages/react-aria-components/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export {
SliderTrack,
SliderThumb,
SliderFill,
SliderMark,
SliderContext,
SliderOutputContext,
SliderTrackContext,
Expand Down Expand Up @@ -457,7 +458,9 @@ export type {
SliderTrackRenderProps,
SliderFillProps,
SliderFillRenderProps,
SliderThumbRenderProps
SliderThumbRenderProps,
SliderMarkProps,
SliderMarkRenderProps
} from '../src/Slider';
export type {
SwitchProps,
Expand Down
128 changes: 128 additions & 0 deletions packages/react-aria-components/src/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import React, {
import {SliderState, useSliderState} from 'react-stately/useSliderState';
import {useFocusRing} from 'react-aria/useFocusRing';
import {useHover} from 'react-aria/useHover';
import {useLocale} from 'react-aria/I18nProvider';
import {useNumberFormatter} from 'react-aria/useNumberFormatter';
import {VisuallyHidden} from 'react-aria/VisuallyHidden';

Expand Down Expand Up @@ -374,6 +375,133 @@ export const SliderThumb = /*#__PURE__*/ (forwardRef as forwardRefType)(function
);
});

export interface SliderMarkRenderProps extends SliderRenderProps {
/**
* The value along the track that the mark is positioned at.
*/
value: number;
/**
* Whether the mark is currently hovered with a mouse.
*
* @selector [data-hovered]
*/
isHovered: boolean;
}

export interface SliderMarkProps
extends HoverEvents, RenderProps<SliderMarkRenderProps>, GlobalDOMAttributes<HTMLDivElement> {
/** The value along the track that the mark is positioned at. */
value: number;
/**
* The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the
* element. A function may be provided to compute the class based on component state.
*
* @default 'react-aria-SliderMark'
*/
className?: ClassNameOrFunction<SliderMarkRenderProps>;
}

/**
* A slider mark labels a value along the track. Pressing it moves the nearest thumb to that value.
*/
export const SliderMark = /*#__PURE__*/ (forwardRef as forwardRefType)(function SliderMark(
props: SliderMarkProps,
ref: ForwardedRef<HTMLDivElement>
) {
let {value, onHoverStart, onHoverEnd, onHoverChange, ...otherProps} = props;
let state = useContext(SliderStateContext)!;
let {direction} = useLocale();
let {hoverProps, isHovered} = useHover({
onHoverStart,
onHoverEnd,
onHoverChange,
isDisabled: state.isDisabled
});

// Marks sit inside the track, which moves the nearest thumb to wherever the pointer went down.
// Stop that here so the thumb lands on the mark's exact value, including for labels rendered
// outside the track's bounds.
let onDown = (e: React.SyntheticEvent) => {
e.stopPropagation();
let index = state.getClosestThumbIndex(value);
if (index < 0 || !state.isThumbEditable(index)) {
return;
}

e.preventDefault();
state.setFocusedThumb(index);
state.setThumbDragging(index, true);
state.setThumbPercent(index, state.getValuePercent(value));
state.setThumbDragging(index, false);
};

let isVertical = state.orientation === 'vertical';
let percent = state.getValuePercent(value);
if (isVertical || direction === 'rtl') {
percent = 1 - percent;
}

let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-SliderMark',
defaultStyle: {
position: 'absolute',
[isVertical ? 'top' : 'left']: `${percent * 100}%`,
transform: 'translate(-50%, -50%)'
},
values: {
orientation: state.orientation,
isDisabled: state.isDisabled,
isHovered,
value,
state
}
});

// Pointer events are dispatched before their mouse and touch equivalents, so only one handler
// acts on an interaction and the rest just stop propagation. JSDOM has no PointerEvent, so tests
// fall through to the mouse and touch handlers.
let hasPointerEvents = typeof PointerEvent !== 'undefined';
let interactions: HTMLAttributes<HTMLDivElement> = !state.isDisabled
? {
onPointerDown: (e: React.PointerEvent) => {
if (e.pointerType === 'mouse' && (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)) {
return;
}
onDown(e);
},
onMouseDown: (e: React.MouseEvent) => {
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (hasPointerEvents) {
e.stopPropagation();
} else {
onDown(e);
}
},
onTouchStart: (e: React.TouchEvent) => {
if (hasPointerEvents) {
e.stopPropagation();
} else {
onDown(e);
}
}
}
: {};

return (
<dom.div
{...mergeProps(otherProps, hoverProps, interactions)}
{...renderProps}
ref={ref}
data-hovered={isHovered || undefined}
data-orientation={state.orientation || undefined}
data-disabled={state.isDisabled || undefined}
/>
);
});

export interface SliderFillRenderProps extends SliderRenderProps {
/**
* Whether the slider fill is currently hovered with a mouse.
Expand Down
110 changes: 108 additions & 2 deletions packages/react-aria-components/stories/Slider.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {Label} from '../src/Label';

import {Meta, StoryFn} from '@storybook/react';
import React from 'react';
import {Slider, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider';
import {Slider, SliderMark, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider';
import styles from '../example/index.css';
import './styles.css';

Expand Down Expand Up @@ -103,7 +103,113 @@ SliderCSS.argTypes = {
}
};

const CustomThumb = ({index, children}: {index: number; children: React.ReactNode}) => {
// Seven ticks over six intervals, matching the macOS Sound output volume slider that
// https://github.com/adobe/react-spectrum/issues/8285 asks for.
const VOLUME_TICKS = [0, 100 / 6, 200 / 6, 50, 400 / 6, 500 / 6, 100];

export const SliderMarks: SliderStory = props => (
<Slider
{...props}
aria-label="Volume"
defaultValue={70}
snapPoints={VOLUME_TICKS}
style={{display: 'flex', alignItems: 'center', gap: 10, width: 360}}>
<SliderTrack
style={{position: 'relative', flex: 1, height: 22, display: 'flex', alignItems: 'center'}}>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
height: 4,
background: '#d4d4d4',
borderRadius: 2
}}
/>
{VOLUME_TICKS.map((value, i) => (
<SliderMark key={i} value={value}>
{({isHovered}) => (
<div
style={{
width: 2,
height: 11,
borderRadius: 1,
background: isHovered ? '#8a8a8a' : '#bdbdbd'
}}
/>
)}
</SliderMark>
))}
<SliderThumb
style={({isDragging}) => ({
width: 11,
height: 22,
top: '50%',
borderRadius: 5.5,
// The thumb goes translucent while dragging, as the macOS slider does.
background: isDragging ? 'rgba(255, 255, 255, 0.45)' : '#fff',
boxShadow: isDragging
? '0 0 0 0.5px rgba(0, 0, 0, 0.09)'
: '0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 1px 3px rgba(0, 0, 0, 0.28)'
})}
/>
</SliderTrack>
<SliderOutput style={{width: 40, textAlign: 'end', fontVariantNumeric: 'tabular-nums'}} />
</Slider>
);

SliderMarks.args = {
isDisabled: false,
snapThreshold: 0.02
};

const LABELLED_MARKS = [-100, -50, 0, 50, 100];

export const SliderMarksWithLabels: SliderStory = props => (
<Slider
{...props}
aria-label="Balance"
defaultValue={0}
minValue={-100}
maxValue={100}
snapPoints={LABELLED_MARKS}
style={{position: 'relative', width: 300}}>
<SliderOutput />
<SliderTrack style={{position: 'relative', height: 30, width: '100%'}}>
<div
style={{position: 'absolute', backgroundColor: 'gray', height: 3, top: 13, width: '100%'}}
/>
{LABELLED_MARKS.map(value => (
<SliderMark key={value} value={value} style={{top: 14}}>
{({isHovered}) => (
<>
<div style={{width: 2, height: 10, backgroundColor: 'gray'}} />
<span
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 12,
textDecoration: isHovered ? 'underline' : undefined
}}>
{value}
</span>
</>
)}
</SliderMark>
))}
<CustomThumb index={0} />
</SliderTrack>
</Slider>
);

SliderMarksWithLabels.args = {
isDisabled: false,
snapThreshold: 0.03
};

const CustomThumb = ({index, children}: {index: number; children?: React.ReactNode}) => {
return (
<SliderThumb
index={index}
Expand Down
Loading