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
5 changes: 5 additions & 0 deletions .changeset/slider-components.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions packages/components/__tests__/Slider.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Slider defaultValue={30}>
<Label>Opacity</Label>
<SliderOutput />
<SliderTrack>
<SliderFill />
<SliderThumb />
</SliderTrack>
</Slider>,
);
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(
<Slider defaultValue={[25, 75]} aria-label="Price range">
<SliderTrack>
{({ state }) => (
<>
<SliderFill />
{state.values.map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a thumb's index is its stable identity
<SliderThumb key={i} index={i} />
))}
</>
)}
</SliderTrack>
</Slider>,
);
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(
<Slider defaultValue={60} isDisabled aria-label="Brightness">
<SliderTrack>
<SliderThumb />
</SliderTrack>
</Slider>,
);
expect(screen.getByRole('slider', { name: 'Brightness' })).toBeDisabled();
});
});
155 changes: 155 additions & 0 deletions packages/components/src/Slider.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends number | number[] = number | number[]> extends AriaSliderProps<T> {
ref?: Ref<HTMLDivElement>;
}

interface SliderTrackProps extends AriaSliderTrackProps {
ref?: Ref<HTMLDivElement>;
}

interface SliderThumbProps extends AriaSliderThumbProps {
ref?: Ref<HTMLDivElement>;
}

interface SliderFillProps extends AriaSliderFillProps {
ref?: Ref<HTMLDivElement>;
}

interface SliderOutputProps extends AriaSliderOutputProps {
ref?: Ref<HTMLOutputElement>;
}

const SliderContext =
createContext<ContextValue<SliderProps<number | number[]>, 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 = <T extends number | number[]>({ ref, ...props }: SliderProps<T>) => {
[props, ref] = useLPContextProps(props, ref, SliderContext);
return (
<AriaSlider
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 (
<AriaSliderTrack
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 (
<AriaSliderThumb
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 (
<AriaSliderFill
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 (
<AriaSliderOutput
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
sliderOutputStyles({ ...renderProps, className }),
)}
/>
);
};

export {
Slider,
SliderContext,
SliderFill,
SliderOutput,
SliderThumb,
SliderTrack,
sliderFillStyles,
sliderOutputStyles,
sliderStyles,
sliderThumbStyles,
sliderTrackStyles,
};
export type { SliderFillProps, SliderOutputProps, SliderProps, SliderThumbProps, SliderTrackProps };
20 changes: 20 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions packages/components/src/styles/Slider.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading