diff --git a/.changeset/slider-components.md b/.changeset/slider-components.md new file mode 100644 index 000000000..789831b87 --- /dev/null +++ b/.changeset/slider-components.md @@ -0,0 +1,5 @@ +--- +'@launchpad-ui/components': minor +--- + +Add `Slider`, `SliderTrack`, `SliderThumb`, `SliderFill`, and `SliderOutput` for selecting one or more values within a range. Supports single and range values, horizontal and vertical orientation, and composes with `Label` for an accessible name. diff --git a/packages/components/__tests__/Slider.spec.tsx b/packages/components/__tests__/Slider.spec.tsx new file mode 100644 index 000000000..2936669fe --- /dev/null +++ b/packages/components/__tests__/Slider.spec.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from '../../../test/utils'; +import { Label, Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack } from '../src'; + +describe('Slider', () => { + it('renders a single-thumb slider composition', () => { + render( + + + + + + + + , + ); + expect(screen.getByRole('group', { name: 'Opacity' })).toBeVisible(); + expect(screen.getByRole('slider', { name: 'Opacity' })).toHaveValue('30'); + expect(screen.getByText('30')).toBeVisible(); + }); + + it('renders a thumb per value for a range slider', () => { + render( + + + {({ state }) => ( + <> + + {state.values.map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a thumb's index is its stable identity + + ))} + + )} + + , + ); + const thumbs = screen.getAllByRole('slider'); + expect(thumbs).toHaveLength(2); + expect(thumbs[0]).toHaveValue('25'); + expect(thumbs[1]).toHaveValue('75'); + }); + + it('applies the disabled state', () => { + render( + + + + + , + ); + expect(screen.getByRole('slider', { name: 'Brightness' })).toBeDisabled(); + }); +}); diff --git a/packages/components/src/Slider.tsx b/packages/components/src/Slider.tsx new file mode 100644 index 000000000..85e37a524 --- /dev/null +++ b/packages/components/src/Slider.tsx @@ -0,0 +1,155 @@ +import type { Ref } from 'react'; +import type { + SliderFillProps as AriaSliderFillProps, + SliderOutputProps as AriaSliderOutputProps, + SliderProps as AriaSliderProps, + SliderThumbProps as AriaSliderThumbProps, + SliderTrackProps as AriaSliderTrackProps, +} from 'react-aria-components/Slider'; +import type { ContextValue } from 'react-aria-components/slots'; + +import { cva } from 'class-variance-authority'; +import { createContext } from 'react'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { + Slider as AriaSlider, + SliderFill as AriaSliderFill, + SliderOutput as AriaSliderOutput, + SliderThumb as AriaSliderThumb, + SliderTrack as AriaSliderTrack, +} from 'react-aria-components/Slider'; + +import styles from './styles/Slider.module.css'; +import { useLPContextProps } from './utils'; + +const sliderStyles = cva(styles.slider); +const sliderTrackStyles = cva(styles.track); +const sliderThumbStyles = cva(styles.thumb); +const sliderFillStyles = cva(styles.fill); +const sliderOutputStyles = cva(styles.output); + +interface SliderProps extends AriaSliderProps { + ref?: Ref; +} + +interface SliderTrackProps extends AriaSliderTrackProps { + ref?: Ref; +} + +interface SliderThumbProps extends AriaSliderThumbProps { + ref?: Ref; +} + +interface SliderFillProps extends AriaSliderFillProps { + ref?: Ref; +} + +interface SliderOutputProps extends AriaSliderOutputProps { + ref?: Ref; +} + +const SliderContext = + createContext, HTMLDivElement>>(null); + +/** + * A slider allows a user to select one or more values within a range. + * + * Compose with `SliderTrack`, `SliderThumb`, `SliderFill`, `SliderOutput`, and `Label`. + * + * https://react-spectrum.adobe.com/react-aria/Slider.html + */ +const Slider = ({ ref, ...props }: SliderProps) => { + [props, ref] = useLPContextProps(props, ref, SliderContext); + return ( + + sliderStyles({ ...renderProps, className }), + )} + /> + ); +}; + +/** + * A slider track is a container for one or more slider thumbs and the slider fill. + * + * https://react-spectrum.adobe.com/react-aria/Slider.html + */ +const SliderTrack = ({ ref, ...props }: SliderTrackProps) => { + return ( + + sliderTrackStyles({ ...renderProps, className }), + )} + /> + ); +}; + +/** + * A slider thumb represents an individual value that the user can adjust within a slider track. + * + * https://react-spectrum.adobe.com/react-aria/Slider.html + */ +const SliderThumb = ({ ref, ...props }: SliderThumbProps) => { + return ( + + sliderThumbStyles({ ...renderProps, className }), + )} + /> + ); +}; + +/** + * A slider fill displays the selected range of a slider. + * + * https://react-spectrum.adobe.com/react-aria/Slider.html + */ +const SliderFill = ({ ref, ...props }: SliderFillProps) => { + return ( + + sliderFillStyles({ ...renderProps, className }), + )} + /> + ); +}; + +/** + * A slider output displays the current value of a slider as text. + * + * https://react-spectrum.adobe.com/react-aria/Slider.html + */ +const SliderOutput = ({ ref, ...props }: SliderOutputProps) => { + return ( + + sliderOutputStyles({ ...renderProps, className }), + )} + /> + ); +}; + +export { + Slider, + SliderContext, + SliderFill, + SliderOutput, + SliderThumb, + SliderTrack, + sliderFillStyles, + sliderOutputStyles, + sliderStyles, + sliderThumbStyles, + sliderTrackStyles, +}; +export type { SliderFillProps, SliderOutputProps, SliderProps, SliderThumbProps, SliderTrackProps }; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 4953771b8..3a138314d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -66,6 +66,13 @@ export type { SearchFieldProps } from './SearchField'; export type { ListBoxSectionProps, MenuSectionProps } from './Section'; export type { SelectProps, SelectValueProps } from './Select'; export type { SeparatorProps } from './Separator'; +export type { + SliderFillProps, + SliderOutputProps, + SliderProps, + SliderThumbProps, + SliderTrackProps, +} from './Slider'; export type { SwitchProps } from './Switch'; export type { CellProps, @@ -269,6 +276,19 @@ export { selectValueStyles, } from './Select'; export { Separator, SeparatorContext, separatorStyles } from './Separator'; +export { + Slider, + SliderContext, + SliderFill, + SliderOutput, + SliderThumb, + SliderTrack, + sliderFillStyles, + sliderOutputStyles, + sliderStyles, + sliderThumbStyles, + sliderTrackStyles, +} from './Slider'; export { Switch, SwitchContext, switchStyles } from './Switch'; export { Cell, diff --git a/packages/components/src/styles/Slider.module.css b/packages/components/src/styles/Slider.module.css new file mode 100644 index 000000000..1fbf130b5 --- /dev/null +++ b/packages/components/src/styles/Slider.module.css @@ -0,0 +1,97 @@ +.slider { + /* + * Single source of truth for the control's sizing. The rail/fill take the + * track thickness; the thumb scales from it, so overriding one variable + * resizes the whole slider proportionally. + */ + --slider-track-size: var(--lp-size-8); + --slider-thumb-size: calc(var(--slider-track-size) * 2.5); + + display: grid; + grid-template-areas: + 'label output' + 'track track'; + grid-template-columns: 1fr auto; + align-items: center; + gap: var(--lp-spacing-200); +} + +.slider[data-orientation='vertical'] { + display: flex; + flex-direction: column; + align-items: center; + width: fit-content; + height: 100%; +} + +.output { + grid-area: output; + font: var(--lp-text-label-1-medium); + color: var(--lp-color-text-ui-primary-base); +} + +/* The track element is itself the visible rail; the fill (which RAC sizes to + 100% of the track) inherits this thickness. */ +.track { + grid-area: track; + position: relative; + background-color: var(--lp-color-bg-ui-tertiary); + border-radius: var(--lp-border-radius-large); +} + +.slider[data-orientation='horizontal'] .track { + width: 100%; + height: var(--slider-track-size); +} + +.slider[data-orientation='vertical'] .track { + width: var(--slider-track-size); + height: 100%; + min-height: var(--lp-size-160); +} + +.fill { + background-color: var(--lp-color-bg-interactive-primary-base); + border-radius: var(--lp-border-radius-large); +} + +.thumb { + width: var(--slider-thumb-size); + height: var(--slider-thumb-size); + border-radius: var(--lp-border-radius-large); + background-color: var(--lp-color-white-950); + border: 1px solid var(--lp-color-border-interactive-secondary-base); + box-shadow: + 0 0 1px 0 rgb(33 33 33 / 0.75), + 0 0 2px 0 rgb(33 33 33 / 0.06), + 0 0 1px 0 rgb(33 33 33 / 0.35); + transition: outline var(--lp-duration-200) ease-in-out; +} + +/* RAC positions the thumb along the track axis and applies + translate(-50%, -50%); the app centers it on the cross axis. */ +.slider[data-orientation='horizontal'] .thumb { + top: 50%; +} + +.slider[data-orientation='vertical'] .thumb { + left: 50%; +} + +.thumb[data-hovered], +.thumb[data-dragging] { + background-color: var(--lp-color-bg-ui-secondary); +} + +.thumb[data-focus-visible] { + outline: 2px solid var(--lp-color-shadow-interactive-focus); + outline-offset: 2px; +} + +.slider[data-disabled] { + opacity: 60%; +} + +.slider[data-disabled] .fill { + background-color: var(--lp-color-bg-ui-tertiary); +} diff --git a/packages/components/stories/Slider.stories.tsx b/packages/components/stories/Slider.stories.tsx new file mode 100644 index 000000000..269956135 --- /dev/null +++ b/packages/components/stories/Slider.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Label } from '../src/Label'; +import { Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack } from '../src/Slider'; + +const meta: Meta = { + component: Slider, + title: 'Components/Forms/Slider', +}; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = { + args: { defaultValue: 30, style: { width: 300 } }, + render: (args) => ( + + + + + + + + + ), +}; + +export const Range: Story = { + args: { defaultValue: [25, 75], style: { width: 300 } }, + render: (args) => ( + + + + {({ state }) => state.values.map((_, i) => state.getThumbValueLabel(i)).join(' – ')} + + + {({ state }) => ( + <> + + {state.values.map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a thumb's index is its stable identity + + ))} + + )} + + + ), +}; + +export const Vertical: Story = { + args: { + defaultValue: 40, + orientation: 'vertical', + 'aria-label': 'Volume', + style: { height: 200 }, + }, + render: (args) => ( + + + + + + + ), +}; + +export const Disabled: Story = { + args: { defaultValue: 60, isDisabled: true, style: { width: 300 } }, + render: (args) => ( + + + + + + + + + ), +}; + +const formats: Array<{ + label: string; + minValue: number; + maxValue: number; + step?: number; + defaultValue: number | number[]; + formatOptions: Intl.NumberFormatOptions; +}> = [ + { + label: 'Budget (currency)', + minValue: 0, + maxValue: 1000, + defaultValue: [250, 750], + formatOptions: { style: 'currency', currency: 'USD' }, + }, + { + label: 'Opacity (percent)', + minValue: 0, + maxValue: 1, + step: 0.01, + defaultValue: [0.2, 0.6], + formatOptions: { style: 'percent' }, + }, + { + label: 'Distance (unit)', + minValue: 0, + maxValue: 100, + defaultValue: 12, + formatOptions: { style: 'unit', unit: 'kilometer' }, + }, + { + label: 'Temperature (unit)', + minValue: -10, + maxValue: 40, + defaultValue: 21, + formatOptions: { style: 'unit', unit: 'celsius' }, + }, + { + label: 'Weight (decimal)', + minValue: 0, + maxValue: 10, + step: 0.1, + defaultValue: 2.5, + formatOptions: { minimumFractionDigits: 2 }, + }, +]; + +export const Formatted: Story = { + render: () => ( +
+ {formats.map(({ label, ...args }) => ( + + + + + {({ state }) => ( + <> + + {state.values.map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a thumb's index is its stable identity + + ))} + + )} + + + ))} +
+ ), +};