diff --git a/apps/www/src/content/docs/components/slider/demo.ts b/apps/www/src/content/docs/components/slider/demo.ts index 787c29cfc..94fc88872 100644 --- a/apps/www/src/content/docs/components/slider/demo.ts +++ b/apps/www/src/content/docs/components/slider/demo.ts @@ -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 ( - setValue(newValue as number)} + onValueChange={(newValue) => setValue(newValue as number)} /> Value {value} @@ -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])} /> Lower {value[0]} Upper {value[1]} @@ -83,16 +84,16 @@ export const thumbSizeDemo = { type: 'code', code: ` - + variant="single" + label="Large Thumb" + defaultValue={50} + thumbSize="large" + /> + ` }; diff --git a/apps/www/src/content/docs/components/slider/index.mdx b/apps/www/src/content/docs/components/slider/index.mdx index b007f29af..938bbe460 100644 --- a/apps/www/src/content/docs/components/slider/index.mdx +++ b/apps/www/src/content/docs/components/slider/index.mdx @@ -18,6 +18,11 @@ import { Slider } from '@raystack/apsara' +## Slider.Value Props + +This component is used to display the current value of the slider. + + ## Examples ### Variant @@ -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. - -## 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 -
- console.log(value)} - /> -
-``` - -### 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 diff --git a/apps/www/src/content/docs/components/slider/props.ts b/apps/www/src/content/docs/components/slider/props.ts index 06b6a99c7..7f5a1da6f 100644 --- a/apps/www/src/content/docs/components/slider/props.ts +++ b/apps/www/src/content/docs/components/slider/props.ts @@ -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; } diff --git a/packages/raystack/components/slider/__tests__/slider.test.tsx b/packages/raystack/components/slider/__tests__/slider.test.tsx index bb4dfbb29..812017bdd 100644 --- a/packages/raystack/components/slider/__tests__/slider.test.tsx +++ b/packages/raystack/components/slider/__tests__/slider.test.tsx @@ -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'; @@ -24,10 +24,12 @@ describe('Slider', () => { expect(slider).toHaveClass('custom-slider'); }); - it('renders track and range', () => { + it('renders track and indicator', () => { const { container } = render(); expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); - expect(container.querySelector(`.${styles.range}`)).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.indicator}`) + ).toBeInTheDocument(); }); it('renders thumb', () => { @@ -55,35 +57,41 @@ describe('Slider', () => { describe('Values', () => { it('uses default min and max values', () => { const { container } = render(); - 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(); - 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(); - 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(); - 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(); - 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'); + }); }); }); @@ -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(); - 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(); - const root = container.querySelector(`.${styles.slider}`); - expect(root).toHaveAttribute('aria-label', 'Slider'); - }); - - it('has default aria-label for range slider', () => { - const { container } = render(); - const root = container.querySelector(`.${styles.slider}`); - expect(root).toHaveAttribute('aria-label', 'Range slider'); - }); - it('uses custom aria-label', () => { const { container } = render(); const root = container.querySelector(`.${styles.slider}`); expect(root).toHaveAttribute('aria-label', 'Audio volume'); }); - - it('sets aria-valuetext', () => { - render(); - 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(); - const slider = screen.getByRole('slider'); + const { container } = render( + + ); - 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( ); - 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(); - 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( ); - 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'); + }); }); }); }); diff --git a/packages/raystack/components/slider/index.tsx b/packages/raystack/components/slider/index.tsx index 2dfe13e0a..611e90646 100644 --- a/packages/raystack/components/slider/index.tsx +++ b/packages/raystack/components/slider/index.tsx @@ -1 +1 @@ -export { Slider } from "./slider"; +export { Slider } from './slider'; diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index 565d8f1e9..8cc1665c3 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -11,18 +11,28 @@ padding: 0; } +.control { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; +} + .track { position: relative; flex-grow: 1; height: var(--rs-space-2); background-color: var(--rs-color-background-neutral-secondary); margin: 0 var(--rs-space-4); + border-radius: var(--rs-radius-full); } -.range { +.indicator { position: absolute; height: 100%; background-color: var(--rs-color-background-accent-emphasis); + border-radius: var(--rs-radius-full); } .thumb { @@ -32,7 +42,6 @@ align-items: center; justify-content: center; outline: none; - transform: translate(-50%, -50%); } .thumb:active { @@ -48,15 +57,25 @@ border-color: var(--rs-color-border-accent-emphasis-hover); } -.thumb svg { - width: 32px; - height: 28px; - fill: var(--rs-color-background-base-primary); - margin-top: var(--rs-space-1); +.thumbLarge { + width: 24px; + height: 20px; + position: relative; + background-color: var(--rs-color-background-base-primary); + border: 0.5px solid var(--rs-color-border-base-tertiary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-soft); + display: flex; + align-items: center; + justify-content: center; + gap: 3px; } -.thumb:hover svg { - fill: var(--rs-color-background-base-secondary); +.thumbLargeLine { + width: 1px; + height: 6px; + background-color: var(--rs-color-border-base-tertiary); + border-radius: var(--rs-radius-1); } .thumbSmall { @@ -68,9 +87,14 @@ box-shadow: var(--rs-shadow-soft); } +.thumb:hover .thumbLarge, +.thumb:hover .thumbSmall { + background-color: var(--rs-color-background-base-secondary); +} + .label { position: absolute; - top: calc(-1 * (var(--rs-space-7) + 1px)); + top: calc(-1 * (var(--rs-space-8) + 1px)); left: 50%; padding: var(--rs-space-2); color: var(--rs-color-foreground-base-primary); @@ -86,6 +110,6 @@ top: calc(-1 * (var(--rs-space-7))); } -.slider-variant-single .range { +.slider-variant-single .indicator { left: 0; } diff --git a/packages/raystack/components/slider/slider.tsx b/packages/raystack/components/slider/slider.tsx index 10a09be99..c277e07b5 100644 --- a/packages/raystack/components/slider/slider.tsx +++ b/packages/raystack/components/slider/slider.tsx @@ -1,15 +1,10 @@ 'use client'; -import { type VariantProps, cva, cx } from 'class-variance-authority'; -import { Slider as SliderPrimitive } from 'radix-ui'; -import { - type ComponentPropsWithoutRef, - type ElementRef, - forwardRef -} from 'react'; +import { Slider as SliderPrimitive } from '@base-ui/react'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { type ElementRef, forwardRef, useCallback } from 'react'; import { Text } from '../text'; import styles from './slider.module.css'; -import { ThumbIcon } from './thumb'; const slider = cva(styles.slider, { variants: { @@ -24,117 +19,83 @@ const slider = cva(styles.slider, { }); export interface SliderProps - extends Omit< - ComponentPropsWithoutRef, - 'value' | 'defaultValue' | 'onChange' - >, + extends SliderPrimitive.Root.Props, VariantProps { - value?: number | [number, number]; - defaultValue?: number | [number, number]; - min?: number; - max?: number; - step?: number; label?: string | [string, string]; - onChange?: (value: number | [number, number]) => void; - 'aria-label'?: string; - 'aria-valuetext'?: string; thumbSize?: 'small' | 'large'; } -export const Slider = forwardRef< +const SliderRoot = forwardRef< ElementRef, SliderProps >( ( - { - className, - variant = 'single', - value, - defaultValue, - min = 0, - max = 100, - step = 1, - label, - onChange, - 'aria-label': ariaLabel, - 'aria-valuetext': ariaValueText, - thumbSize = 'large', - ...props - }, + { className, variant = 'single', label, thumbSize = 'large', ...props }, ref ) => { const isRange = variant === 'range'; const isThumbSmall = thumbSize === 'small'; - const defaultVal = isRange - ? (defaultValue as [number, number]) || [min, max] - : [(defaultValue as number) || min]; - const currentValue = value - ? isRange - ? (value as [number, number]) - : [value as number] - : defaultVal; - const getLabel = (index: number) => { - if (!label) return undefined; - if (typeof label === 'string') return label; - return label[index]; - }; + const getLabel = useCallback( + (index: number) => { + if (!label) return undefined; + if (typeof label === 'string') return label; + return label[index]; + }, + [label] + ); - const getAriaValueText = (index: number) => { - if (ariaValueText) return ariaValueText; - const labelText = getLabel(index); - const val = currentValue[index]; - return labelText ? `${labelText}: ${val}` : `${val}`; - }; + const thumbCount = isRange ? 2 : 1; return ( - onChange?.(isRange ? (val as [number, number]) : val[0]) - } - aria-label={ariaLabel || (isRange ? 'Range slider' : 'Slider')} + className={slider({ variant, className })} + thumbAlignment='edge' {...props} > - - - - {defaultVal.map((_, i) => ( - -
- {isThumbSmall ? ( -
- ) : ( - - )} - {getLabel(i) && ( - - {getLabel(i)} - - )} -
- - ))} + + + + {Array.from({ length: thumbCount }).map((_, i) => ( + + {isThumbSmall ? ( +
+ ) : ( +
+
+
+
+
+ )} + {getLabel(i) && ( + + {getLabel(i)} + + )} + + ))} + + ); } ); -Slider.displayName = 'Slider'; +SliderRoot.displayName = 'SliderRoot'; + +export const Slider = Object.assign(SliderRoot, { + Value: SliderPrimitive.Value +}); diff --git a/packages/raystack/components/slider/thumb.tsx b/packages/raystack/components/slider/thumb.tsx deleted file mode 100644 index 87cae59bf..000000000 --- a/packages/raystack/components/slider/thumb.tsx +++ /dev/null @@ -1,31 +0,0 @@ -export const ThumbIcon = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - -);