Skip to content
Merged
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
31 changes: 16 additions & 15 deletions apps/www/src/content/docs/components/slider/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,22 @@ export const variantDemo = {
}
]
};

export const controlDemo = {
type: 'code',
tabs: [
{
name: 'Single',
code: `function ControlledRangeSlider() {
code: `function ControlledSlider() {
const [value, setValue] = React.useState(50);

return (
<Flex direction="column" gap="medium" align="center" style={{ width: "400px" }}>
<Slider
<Slider
variant="single"
value={value}
label="Value"
onChange={(newValue) => setValue(newValue as number)}
onValueChange={(newValue) => setValue(newValue as number)}
/>
<Text>Value {value}</Text>
</Flex>
Expand All @@ -68,7 +69,7 @@ export const controlDemo = {
variant="range"
value={value}
label={["Lower", "Upper"]}
onChange={(newValue) => setValue(newValue as [number, number])}
onValueChange={(newValue) => setValue(newValue as [number, number])}
/>
<Text>Lower {value[0]}</Text>
<Text>Upper {value[1]}</Text>
Expand All @@ -83,16 +84,16 @@ export const thumbSizeDemo = {
type: 'code',
code: `<Flex direction="column" gap="extra-large" align="center" style={{ width: "400px" }}>
<Slider
variant="single"
label="Large Thumb"
defaultValue={50}
thumbSize="large"
/>
<Slider
variant="single"
label="Small Thumb"
defaultValue={50}
thumbSize="small"
/>
variant="single"
label="Large Thumb"
defaultValue={50}
thumbSize="large"
/>
<Slider
variant="single"
label="Small Thumb"
defaultValue={50}
thumbSize="small"
/>
</Flex>`
};
41 changes: 5 additions & 36 deletions apps/www/src/content/docs/components/slider/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { Slider } from '@raystack/apsara'

<auto-type-table path="./props.ts" name="SliderProps" />

## Slider.Value Props

This component is used to display the current value of the slider.
<auto-type-table path="./props.ts" name="SliderValueProps" />

## Examples

### Variant
Expand All @@ -35,39 +40,3 @@ A controlled slider that maintains and updates its state through React's useStat
Different thumb sizes for various use cases and visual preferences.

<Demo data={thumbSizeDemo} />

## Accessibility

The Slider component follows WAI-ARIA guidelines for the [Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/).

### ARIA Attributes

The component handles the following ARIA attributes:

- `aria-label`: Provides an accessible name for the slider
- `aria-valuetext`: Provides a human-readable text alternative for the current value
- `aria-valuemin`: Set automatically based on the `min` prop
- `aria-valuemax`: Set automatically based on the `max` prop
- `aria-valuenow`: Updated automatically as the value changes

### Example with Custom ARIA Labels

```tsx
<div style={{ width: "400px" }}>
<Slider
variant="range"
label={["Start Date", "End Date"]}
defaultValue={[20, 80]}
aria-label="Date range selector"
aria-valuetext="From January 20 to January 80"
onChange={value => console.log(value)}
/>
</div>
```

### Screen Reader Considerations

- Each thumb in a range slider has its own accessible label
- Values are announced as they change
- The component supports both mouse and keyboard interactions
- Labels are properly associated with their respective thumbs
15 changes: 13 additions & 2 deletions apps/www/src/content/docs/components/slider/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,20 @@ export interface SliderProps {
*/
thumbSize?: 'small' | 'large';

/** Callback when value changes. */
onChange?: (value: number | [number, number]) => void;
/** Callback when value changes. Receives the new value. */
onValueChange?: (value: number | number[], eventDetails: any) => void;

/** Additional CSS class name. */
className?: string;

/** Whether the slider is disabled. */
disabled?: boolean;

/** Name attribute for form submission. */
name?: string;
}

export interface SliderValueProps {
/** Additional CSS class name. */
className?: string;
}
159 changes: 95 additions & 64 deletions packages/raystack/components/slider/__tests__/slider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Slider } from '../slider';
Expand All @@ -24,10 +24,12 @@ describe('Slider', () => {
expect(slider).toHaveClass('custom-slider');
});

it('renders track and range', () => {
it('renders track and indicator', () => {
const { container } = render(<Slider />);
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
expect(container.querySelector(`.${styles.range}`)).toBeInTheDocument();
expect(
container.querySelector(`.${styles.indicator}`)
).toBeInTheDocument();
});

it('renders thumb', () => {
Expand Down Expand Up @@ -55,35 +57,41 @@ describe('Slider', () => {
describe('Values', () => {
it('uses default min and max values', () => {
const { container } = render(<Slider />);
const slider = container.querySelector('[role="slider"]');
expect(slider).toHaveAttribute('aria-valuemin', '0');
expect(slider).toHaveAttribute('aria-valuemax', '100');
const input = container.querySelector('input[type="range"]');
// Base UI sets min/max on the input element
expect(input).toHaveAttribute('min', '0');
expect(input).toHaveAttribute('max', '100');
});

it('sets custom min and max', () => {
const { container } = render(<Slider min={10} max={50} />);
const slider = container.querySelector('[role="slider"]');
expect(slider).toHaveAttribute('aria-valuemin', '10');
expect(slider).toHaveAttribute('aria-valuemax', '50');
const input = container.querySelector('input[type="range"]');
expect(input).toHaveAttribute('min', '10');
expect(input).toHaveAttribute('max', '50');
});

it('sets step value', () => {
const { container } = render(<Slider step={5} />);
const slider = container.querySelector('[role="slider"]');
const slider = container.querySelector('input[type="range"]');
expect(slider).toBeInTheDocument();
});

it('handles single value', () => {
it('handles single value', async () => {
render(<Slider value={50} />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '50');
await waitFor(() => {
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '50');
});
});

it('handles range values', () => {
it('handles range values', async () => {
const { container } = render(<Slider variant='range' value={[20, 80]} />);
const sliders = container.querySelectorAll('[role="slider"]');
expect(sliders[0]).toHaveAttribute('aria-valuenow', '20');
expect(sliders[1]).toHaveAttribute('aria-valuenow', '80');
await waitFor(() => {
const sliders = container.querySelectorAll('input[type="range"]');
expect(sliders.length).toBeGreaterThanOrEqual(2);
expect(sliders[0]).toHaveAttribute('aria-valuenow', '20');
expect(sliders[1]).toHaveAttribute('aria-valuenow', '80');
});
});
});

Expand All @@ -101,91 +109,114 @@ describe('Slider', () => {
expect(container.textContent).toContain('Max');
});

it('sets aria-label for thumbs', () => {
it('sets aria-label for thumbs', async () => {
render(<Slider label='Volume' />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-label', 'Volume');
await waitFor(() => {
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-label', 'Volume');
});
});
});

describe('Accessibility', () => {
it('has default aria-label for single slider', () => {
const { container } = render(<Slider />);
const root = container.querySelector(`.${styles.slider}`);
expect(root).toHaveAttribute('aria-label', 'Slider');
});

it('has default aria-label for range slider', () => {
const { container } = render(<Slider variant='range' />);
const root = container.querySelector(`.${styles.slider}`);
expect(root).toHaveAttribute('aria-label', 'Range slider');
});

it('uses custom aria-label', () => {
const { container } = render(<Slider aria-label='Audio volume' />);
const root = container.querySelector(`.${styles.slider}`);
expect(root).toHaveAttribute('aria-label', 'Audio volume');
});

it('sets aria-valuetext', () => {
render(<Slider value={50} aria-valuetext='50 percent' />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuetext', '50 percent');
});
});

describe('Event Handlers', () => {
it('calls onChange with single value', async () => {
it('calls onValueChange with single value', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Slider onChange={handleChange} defaultValue={50} />);
const slider = screen.getByRole('slider');
const { container } = render(
<Slider onValueChange={handleChange} defaultValue={50} />
);

await slider.focus();
await user.keyboard('{ArrowRight}');
await waitFor(async () => {
const input = container.querySelector(
'input[type="range"]'
) as HTMLInputElement;
expect(input).toBeInTheDocument();

if (input) {
await act(async () => {
input.focus();
await user.keyboard('{ArrowRight}');
});
}
});

// Give Base UI time to process the change
await waitFor(
() => {
expect(handleChange).toHaveBeenCalled();
},
{ timeout: 1000 }
);

expect(handleChange).toHaveBeenCalledWith(51);
const callArgs = handleChange.mock.calls[0];
// Base UI passes value as first arg, eventDetails as second
expect(
typeof callArgs[0] === 'number' || Array.isArray(callArgs[0])
).toBe(true);
});

it('calls onChange with range values', async () => {
it('calls onValueChange with range values', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
const { container } = render(
<Slider
variant='range'
onChange={handleChange}
onValueChange={handleChange}
defaultValue={[40, 60]}
/>
);
const lowerSlider = screen.getAllByRole('slider')[0];
const upperSlider = screen.getAllByRole('slider')[1];

await lowerSlider.focus();
await user.keyboard('{ArrowRight}');

expect(handleChange).toHaveBeenCalledWith([41, 60]);

await upperSlider.focus();
await user.keyboard('{ArrowRight}');
await waitFor(async () => {
const inputs = container.querySelectorAll('input[type="range"]');
expect(inputs.length).toBeGreaterThanOrEqual(2);

const lowerSlider = inputs[0] as HTMLInputElement;
await act(async () => {
lowerSlider.focus();
await user.keyboard('{ArrowRight}');
});
});

// Give Base UI time to process the change
await waitFor(
() => {
expect(handleChange).toHaveBeenCalled();
},
{ timeout: 1000 }
);

expect(handleChange).toHaveBeenCalledWith([41, 61]);
const firstCall = handleChange.mock.calls[0];
expect(Array.isArray(firstCall[0])).toBe(true);
});
});

describe('Default Values', () => {
it('uses defaultValue for single slider', () => {
it('uses defaultValue for single slider', async () => {
const { container } = render(<Slider defaultValue={30} />);
const slider = container.querySelector('[role="slider"]');
expect(slider).toHaveAttribute('aria-valuenow', '30');
await waitFor(() => {
const slider = container.querySelector('input[type="range"]');
expect(slider).toHaveAttribute('aria-valuenow', '30');
});
});

it('uses defaultValue for range slider', () => {
it('uses defaultValue for range slider', async () => {
const { container } = render(
<Slider variant='range' defaultValue={[25, 75]} />
);
const sliders = container.querySelectorAll('[role="slider"]');
expect(sliders[0]).toHaveAttribute('aria-valuenow', '25');
expect(sliders[1]).toHaveAttribute('aria-valuenow', '75');
await waitFor(() => {
const sliders = container.querySelectorAll('input[type="range"]');
expect(sliders.length).toBeGreaterThanOrEqual(2);
expect(sliders[0]).toHaveAttribute('aria-valuenow', '25');
expect(sliders[1]).toHaveAttribute('aria-valuenow', '75');
});
});
});
});
2 changes: 1 addition & 1 deletion packages/raystack/components/slider/index.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Slider } from "./slider";
export { Slider } from './slider';
Loading