diff --git a/package.json b/package.json index 7c4146e4..7bc8d038 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-slider": "1.3.6", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "spinners-react": "1.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d62ac2aa..a0d57e91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: 2.1.16 version: 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: 1.3.6 + version: 1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -889,6 +892,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1128,6 +1134,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1200,6 +1219,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -5166,6 +5194,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -5399,6 +5429,25 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-slot@1.2.3(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) @@ -5454,6 +5503,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.1 diff --git a/src/components/atoms/slider/Slider.stories.tsx b/src/components/atoms/slider/Slider.stories.tsx new file mode 100644 index 00000000..4e673ecd --- /dev/null +++ b/src/components/atoms/slider/Slider.stories.tsx @@ -0,0 +1,334 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ReactNode, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Slider } from './Slider'; + +/** + * ## Description + * Slider lets users choose one numeric value or an explicit two-value range within a bounded interval. + * Use it for continuous preferences such as volume, brightness, storage, or price ranges. + * + * ## Dependencies + * Uses `@radix-ui/react-slider` for pointer, drag, focus, keyboard, range, and disabled behavior. + * + * ## Usage Guide + * Values are always `number[]`: one item renders single-value mode, and two items render explicit range mode. + * When uncontrolled values are omitted, Slider initializes from `[min]` rather than hard-coding `[0]`. + * Range mode should provide distinguishable thumb names with `thumbLabels`; otherwise the component falls back to + * `Minimum value` and `Maximum value`. Use `color` and `rounded` to align Slider with nearby Progress indicators. + * Visible labels, helper text, and output values are composed externally in the MVP. + */ +const meta: Meta = { + title: 'Atoms/Slider', + component: Slider, + parameters: { docs: { autodocs: true } }, + tags: ['autodocs'] +}; +export default meta; + +type Story = StoryObj; + +const frame = (children: ReactNode, gapClass = 'gap-3') => ( +
+ {children} +
+); + +/** + * Shows the default uncontrolled single-value Slider with its default size and full-width behavior. + */ +export const Default: Story = { + args: { ariaLabel: 'Volume', onValueChange: action('slider-change') }, + render: (args) => frame() +}; + +/** + * Shows a controlled single-value Slider with externally composed label and output text. + */ +export const Controlled: Story = { + render: () => { + const [value, setValue] = useState([40]); + const logChange = action('controlled-slider-change'); + return frame( + <> +
+ Volume + {value[0]} +
+ { + logChange(next); + setValue(next); + }} + /> + + ); + } +}; + +/** + * Shows explicit two-thumb range mode with distinguishable accessible names for each thumb. + */ +export const Range: Story = { + render: () => { + const [value, setValue] = useState([25, 75]); + const logChange = action('range-slider-change'); + return frame( + <> +
+ Price range + + {value[0]} – {value[1]} + +
+ { + logChange(next); + setValue(next); + }} + /> + + ); + } +}; + +/** + * Shows the non-interactive disabled state with opacity-based disabled treatment. + */ +export const Disabled: Story = { + args: { + ariaLabel: 'Disabled volume', + defaultValue: [40], + disabled: true, + onValueChange: action('disabled-slider-change') + }, + render: (args) => frame() +}; + +/** + * Shows the supported size variants while preserving the same 44px thumb hit area. + */ +export const Sizes: Story = { + render: () => + frame( + <> +
+ Small + +
+
+ Medium + +
+
+ Large + +
+ , + 'gap-6' + ) +}; + +/** + * Shows the available color variants. + */ +export const Colors: Story = { + render: () => + frame( + <> +
+ Default + +
+
+ Primary + +
+
+ Secondary + +
+
+ Success + +
+
+ Warning + +
+
+ Danger + +
+ , + 'gap-6' + ) +}; + +/** + * Shows the available rounded variants. + */ +export const Rounded: Story = { + render: () => + frame( + <> +
+ None + +
+
+ Small + +
+
+ Medium + +
+
+ Large + +
+
+ Full + +
+ , + 'gap-6' + ) +}; + +/** + * Shows how to associate Slider with a visible external label using `aria-labelledby`. + */ +export const WithExternalLabel: Story = { + render: () => + frame( + <> + + + + ) +}; + +/** + * Shows externally composed field help text and current value output without adding built-in field layout props. + */ +export const WithFieldDescription: Story = { + render: () => { + const [value, setValue] = useState([60]); + const logChange = action('storage-slider-change'); + return frame( + <> +
+ Storage allocation + {value[0]} GB +
+ { + logChange(next); + setValue(next); + }} + /> +

+ Choose the percentage of available storage reserved for this workspace. +

+ + ); + } +}; diff --git a/src/components/atoms/slider/Slider.test.tsx b/src/components/atoms/slider/Slider.test.tsx new file mode 100644 index 00000000..b922dfa8 --- /dev/null +++ b/src/components/atoms/slider/Slider.test.tsx @@ -0,0 +1,248 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Slider as RootSlider } from '../../../index'; +import { Slider } from './Slider'; +import { useSlider } from './useSlider'; + +class ResizeObserverMock implements ResizeObserver { + observe = () => undefined; + unobserve = () => undefined; + disconnect = () => undefined; +} +globalThis.ResizeObserver = ResizeObserverMock; + +describe('useSlider — logic', () => { + it('resolves omitted and controlled values for single-value mode', () => { + expect(renderHook(() => useSlider({ ariaLabel: 'Volume' })).result.current.rootProps.defaultValue).toEqual([0]); + expect( + renderHook(() => useSlider({ ariaLabel: 'Volume', min: 20, max: 80 })).result.current.rootProps.defaultValue + ).toEqual([20]); + const controlled = renderHook(() => useSlider({ ariaLabel: 'Volume', value: [40] })).result.current.rootProps; + expect(controlled.value).toEqual([40]); + expect(controlled.defaultValue).toBeUndefined(); + }); + + it('returns one thumb and maps accessible names for single-value usage', () => { + expect(renderHook(() => useSlider({ ariaLabel: 'Volume', defaultValue: [25] })).result.current.thumbs).toHaveLength( + 1 + ); + expect(renderHook(() => useSlider({ ariaLabel: 'Volume' })).result.current.thumbs[0]).toMatchObject({ + 'aria-label': 'Volume' + }); + expect(renderHook(() => useSlider({ 'aria-label': 'Volume level' })).result.current.thumbs[0]).toMatchObject({ + 'aria-label': 'Volume level' + }); + const labelled = renderHook(() => useSlider({ ariaLabel: 'Ignored', 'aria-labelledby': 'volume-label' })).result + .current.thumbs[0]; + expect(labelled?.['aria-labelledby']).toBe('volume-label'); + expect(labelled?.['aria-label']).toBeUndefined(); + }); + + it('falls back to a safe single-thumb accessible name when labels are omitted', () => { + expect(renderHook(() => useSlider({ defaultValue: [25] })).result.current.thumbs[0]).toMatchObject({ + 'aria-label': 'Slider value' + }); + }); + + it('resolves explicit range values and truncates unsupported extra thumbs', () => { + const defaultRange = renderHook(() => + useSlider({ defaultValue: [25, 75], thumbLabels: ['Minimum price', 'Maximum price'] }) + ).result.current; + expect(defaultRange.rootProps.defaultValue).toEqual([25, 75]); + expect(defaultRange.thumbs).toHaveLength(2); + + const controlledRange = renderHook(() => + useSlider({ value: [10, 90], thumbLabels: ['Minimum value', 'Maximum value'] }) + ).result.current; + expect(controlledRange.rootProps.value).toEqual([10, 90]); + expect(controlledRange.rootProps.defaultValue).toBeUndefined(); + expect(controlledRange.thumbs).toHaveLength(2); + + const truncated = renderHook(() => useSlider({ value: [10, 50, 90], thumbLabels: ['Lower bound', 'Upper bound'] })) + .result.current; + expect(truncated.rootProps.value).toEqual([10, 50]); + expect(truncated.thumbs).toHaveLength(2); + }); + + it('maps range thumb labels and range fallback labels to distinguishable thumbs', () => { + const labelled = renderHook(() => + useSlider({ defaultValue: [25, 75], thumbLabels: ['Minimum price', 'Maximum price'] }) + ).result.current.thumbs; + expect(labelled[0]).toMatchObject({ 'aria-label': 'Minimum price' }); + expect(labelled[1]).toMatchObject({ 'aria-label': 'Maximum price' }); + + const fallback = renderHook(() => useSlider({ defaultValue: [25, 75] })).result.current.thumbs; + expect(fallback[0]).toMatchObject({ 'aria-label': 'Minimum value' }); + expect(fallback[1]).toMatchObject({ 'aria-label': 'Maximum value' }); + }); + + it('normalizes invalid numeric domain props without inferring range from min and max', () => { + const invalidDomain = renderHook(() => + useSlider({ defaultValue: [Number.NaN], min: Number.NaN, max: Number.NEGATIVE_INFINITY, step: 0 }) + ).result.current.rootProps; + expect(invalidDomain.min).toBe(0); + expect(invalidDomain.max).toBe(100); + expect(invalidDomain.step).toBe(1); + expect(invalidDomain.defaultValue).toEqual([0]); + + const boundedSingle = renderHook(() => useSlider({ min: 20, max: 80 })).result.current; + expect(boundedSingle.rootProps.defaultValue).toEqual([20]); + expect(boundedSingle.thumbs).toHaveLength(1); + }); + + it('clamps controlled and default values to the resolved numeric domain', () => { + const controlled = renderHook(() => useSlider({ ariaLabel: 'Volume', min: 20, max: 80, value: [10, 120] })).result + .current.rootProps; + expect(controlled.value).toEqual([20, 80]); + + const uncontrolled = renderHook(() => useSlider({ ariaLabel: 'Volume', min: 20, max: 80, defaultValue: [10, 120] })) + .result.current.rootProps; + expect(uncontrolled.defaultValue).toEqual([20, 80]); + }); + + it('sorts two-thumb values so range labels match minimum and maximum semantics', () => { + const controlled = renderHook(() => useSlider({ ariaLabel: 'Price', value: [80, 20] })).result.current.rootProps; + expect(controlled.value).toEqual([20, 80]); + + const uncontrolled = renderHook(() => useSlider({ ariaLabel: 'Price', defaultValue: [80, 20] })).result.current + .rootProps; + expect(uncontrolled.defaultValue).toEqual([20, 80]); + }); +}); + +describe('Slider — component behavior', () => { + it('renders one named slider thumb with value semantics', () => { + render(); + const slider = screen.getByRole('slider', { name: 'Volume' }); + expect(screen.getAllByRole('slider')).toHaveLength(1); + expect(slider).toHaveAttribute('aria-valuemin', '20'); + expect(slider).toHaveAttribute('aria-valuemax', '80'); + expect(slider).toHaveAttribute('aria-valuenow', '40'); + }); + + it('reports controlled single-value changes from Radix keyboard behavior where practical', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + const ControlledSlider = () => { + const [value, setValue] = useState([40]); + return ( + { + handleChange(next); + setValue(next); + }} + /> + ); + }; + render(); + screen.getByRole('slider', { name: 'Volume' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(handleChange).toHaveBeenCalledWith([45]); + expect(screen.getByRole('slider', { name: 'Volume' })).toHaveAttribute('aria-valuenow', '45'); + }); + + it('keeps Home and End keyboard behavior delegated to Radix bounds handling', async () => { + const user = userEvent.setup(); + const ControlledSlider = () => { + const [value, setValue] = useState([40]); + return ; + }; + render(); + const slider = screen.getByRole('slider', { name: 'Volume' }); + + slider.focus(); + await user.keyboard('{End}'); + expect(slider).toHaveAttribute('aria-valuenow', '80'); + + await user.keyboard('{Home}'); + expect(slider).toHaveAttribute('aria-valuenow', '20'); + }); + + it('renders two named thumbs for explicit range mode', () => { + render(); + const sliders = screen.getAllByRole('slider'); + expect(sliders).toHaveLength(2); + expect(screen.getByRole('slider', { name: 'Minimum price' })).toHaveAttribute('aria-valuenow', '25'); + expect(screen.getByRole('slider', { name: 'Maximum price' })).toHaveAttribute('aria-valuenow', '75'); + }); + + it('combines external field context with distinguishable thumb names in range mode', () => { + render( + <> + Price range + + + ); + + const minimum = screen.getByRole('slider', { name: 'Price range Minimum price' }); + const maximum = screen.getByRole('slider', { name: 'Price range Maximum price' }); + + expect(minimum).toHaveAttribute('aria-valuenow', '25'); + expect(maximum).toHaveAttribute('aria-valuenow', '75'); + expect(minimum).toHaveAccessibleName('Price range Minimum price'); + expect(maximum).toHaveAccessibleName('Price range Maximum price'); + }); + + it('renders distinguishable fallback names for range mode without thumbLabels', () => { + render(); + expect(screen.getByRole('slider', { name: 'Minimum value' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Maximum value' })).toBeInTheDocument(); + }); + + it('does not call onValueChange from keyboard interaction when disabled', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(); + const slider = screen.getByRole('slider', { name: 'Volume' }); + slider.focus(); + await user.keyboard('{ArrowRight}'); + expect(slider).toHaveAttribute('aria-disabled', 'true'); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('does not call onValueChange from keyboard interaction when range mode is disabled', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + ); + const lower = screen.getByRole('slider', { name: 'Minimum price' }); + lower.focus(); + await user.keyboard('{ArrowRight}'); + expect(lower).toHaveAttribute('aria-disabled', 'true'); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('passes through onValueCommit, name, and safe native props through Radix root props', () => { + const handleCommit = vi.fn(); + const { result } = renderHook(() => + useSlider({ ariaLabel: 'Volume', id: 'volume-slider', name: 'volume', onValueCommit: handleCommit }) + ); + expect(result.current.rootProps.onValueCommit).toBe(handleCommit); + expect(result.current.rootProps.id).toBe('volume-slider'); + expect(result.current.rootProps.name).toBe('volume'); + }); + + it('maps aria-describedby to the rendered thumb for external field descriptions', () => { + render(); + expect(screen.getByRole('slider', { name: 'Storage' })).toHaveAttribute('aria-describedby', 'storage-description'); + }); + + it('resolves Slider from the root public export', () => { + expect(RootSlider).toBe(Slider); + }); +}); diff --git a/src/components/atoms/slider/Slider.tsx b/src/components/atoms/slider/Slider.tsx new file mode 100644 index 00000000..67dcb351 --- /dev/null +++ b/src/components/atoms/slider/Slider.tsx @@ -0,0 +1,33 @@ +import * as SliderPrimitive from '@radix-ui/react-slider'; +import type { FC } from 'react'; +import type { SliderProps } from './types'; +import { useSlider } from './useSlider'; + +export const Slider: FC = (props) => { + const { rootProps, trackClassName, rangeClassName, thumbs } = useSlider(props); + + return ( + + + + + {thumbs.map((thumb) => ( + + {thumb.hiddenLabel ? ( + + {thumb.hiddenLabel.text} + + ) : null} + + ))} + + ); +}; diff --git a/src/components/atoms/slider/index.ts b/src/components/atoms/slider/index.ts new file mode 100644 index 00000000..75f0f274 --- /dev/null +++ b/src/components/atoms/slider/index.ts @@ -0,0 +1,2 @@ +export { Slider } from './Slider'; +export * from './types'; diff --git a/src/components/atoms/slider/types.ts b/src/components/atoms/slider/types.ts new file mode 100644 index 00000000..a8a27617 --- /dev/null +++ b/src/components/atoms/slider/types.ts @@ -0,0 +1,156 @@ +import type * as SliderPrimitive from '@radix-ui/react-slider'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; + +export const sliderRootVariants = cva( + 'relative flex touch-none select-none items-center data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40', + { variants: { fullWidth: { true: 'w-full', false: 'w-auto min-w-44' } }, defaultVariants: { fullWidth: true } } +); + +export const sliderTrackVariants = cva( + 'relative mx-5 grow overflow-hidden border transition-[background-color,border-color] duration-150 ease-out motion-reduce:transition-none', + { + variants: { + size: { sm: 'h-1', md: 'h-2', lg: 'h-3' }, + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + }, + color: { + default: 'border-red-tint-low bg-red-tint-subtle dark:border-red-tint-active dark:bg-red-tint-low', + primary: 'border-red-tint-active bg-red-tint-low dark:border-red-tint-strong dark:bg-red-tint-active', + secondary: 'border-border-light bg-border-light dark:border-border-dark dark:bg-surface-raised-dark', + success: 'border-success-tint bg-success-tint', + warning: 'border-warning-tint bg-warning-tint', + danger: 'border-error-tint bg-error-tint' + } + }, + defaultVariants: { size: 'md', rounded: 'full', color: 'default' } + } +); + +export const sliderRangeVariants = cva('absolute h-full', { + variants: { + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + }, + color: { + default: 'bg-brand-light dark:bg-brand-dark', + primary: 'bg-brand-light-dark dark:bg-brand-dark-light', + secondary: 'bg-text-secondary-light dark:bg-border-strong-dark', + success: 'bg-success-light dark:bg-success', + warning: 'bg-warning-light dark:bg-warning', + danger: 'bg-error-light dark:bg-error' + } + }, + defaultVariants: { rounded: 'full', color: 'default' } +}); + +export const sliderThumbVariants = cva( + 'group flex size-11 items-center justify-center transition-transform duration-150 ease-out active:scale-95 focus-visible:outline-none motion-reduce:transition-none disabled:pointer-events-none disabled:cursor-not-allowed', + { + variants: { + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + } + }, + defaultVariants: { rounded: 'full' } + } +); + +export const sliderThumbVisualVariants = cva( + 'pointer-events-none block border bg-background-light transition-[background-color,border-color,box-shadow] duration-150 ease-out group-focus-visible:shadow-glow-focus-light motion-reduce:transition-none dark:bg-surface-dark dark:group-focus-visible:shadow-glow-focus-dark', + { + variants: { + size: { sm: 'size-3', md: 'size-4', lg: 'size-5' }, + rounded: { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full' + }, + color: { + default: + 'border-brand-light group-hover:border-brand-light-light dark:border-brand-dark dark:group-hover:border-brand-dark-light', + primary: + 'border-brand-light-dark group-hover:border-brand-light-light dark:border-brand-dark-light dark:group-hover:border-brand-dark-lighter', + secondary: + 'border-text-secondary-light group-hover:border-text-light dark:border-border-strong-dark dark:group-hover:border-text-secondary-dark', + success: + 'border-success-light group-hover:border-success dark:border-success dark:group-hover:border-green-light', + warning: + 'border-warning-light group-hover:border-warning dark:border-warning dark:group-hover:border-yellow-light', + danger: 'border-error-light group-hover:border-error dark:border-error dark:group-hover:border-brand-dark-light' + } + }, + defaultVariants: { size: 'md', rounded: 'full', color: 'default' } + } +); + +type RootVariants = VariantProps; +type TrackVariants = VariantProps; +type RangeVariants = VariantProps; +type RadixRootProps = ComponentProps; +type NativeSliderProps = Omit< + RadixRootProps, + | 'asChild' + | 'children' + | 'className' + | 'defaultValue' + | 'dir' + | 'inverted' + | 'max' + | 'min' + | 'onValueChange' + | 'onValueCommit' + | 'orientation' + | 'step' + | 'value' +>; + +export type SliderSize = NonNullable; +export type SliderColor = NonNullable; +export type SliderRounded = NonNullable; + +export type SliderProps = NativeSliderProps & { + /** Controlled value as a one-item single slider or two-item range `number[]`. @control object */ + value?: number[]; + /** Initial value as a one-item single slider or two-item range `number[]`. @control object */ + defaultValue?: number[]; + onValueChange?: (value: number[]) => void; + onValueCommit?: (value: number[]) => void; + /** @control number @default 0 */ + min?: number; + /** @control number @default 100 */ + max?: number; + /** @control number @default 1 */ + step?: number; + /** @control boolean @default false */ + disabled?: boolean; + /** @control select @default md */ + size?: SliderSize; + /** @control select @default default */ + color?: SliderColor; + /** @control select @default full */ + rounded?: SliderRounded; + /** @control boolean @default true */ + fullWidth?: NonNullable; + /** Convenience accessible name for the single thumb. @control text */ + ariaLabel?: string; + /** Distinguishable accessible names for explicit two-thumb range mode. @control object */ + thumbLabels?: [string, string]; + /** @control text */ + className?: string; +}; diff --git a/src/components/atoms/slider/useSlider.ts b/src/components/atoms/slider/useSlider.ts new file mode 100644 index 00000000..92673606 --- /dev/null +++ b/src/components/atoms/slider/useSlider.ts @@ -0,0 +1,207 @@ +import type * as SliderPrimitive from '@radix-ui/react-slider'; +import { type ComponentProps, useId } from 'react'; +import { cn } from '@/lib/utils'; +import { + type SliderProps, + sliderRangeVariants, + sliderRootVariants, + sliderThumbVariants, + sliderThumbVisualVariants, + sliderTrackVariants +} from './types'; + +type SliderRootProps = ComponentProps; +type SliderThumbProps = { + key: string; + className: string; + visualClassName: string; + 'aria-describedby'?: string; + 'aria-disabled'?: boolean; + 'aria-label'?: string; + 'aria-labelledby'?: string; + hiddenLabel?: { + id: string; + text: string; + }; +}; + +export type UseSliderReturn = { + rootProps: SliderRootProps; + trackClassName: string; + rangeClassName: string; + thumbs: SliderThumbProps[]; +}; + +const finite = (value: number | undefined, fallback: number): number => + Number.isFinite(value) ? Number(value) : fallback; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +const normalizeSliderValues = ( + value: number[] | undefined, + fallback: number[], + min: number, + max: number +): number[] | undefined => { + if (value === undefined) { + return undefined; + } + + const finiteValues = value.filter((item) => Number.isFinite(item)).slice(0, 2); + if (finiteValues.length === 0) { + return fallback; + } + + const clampedValues = finiteValues.map((item) => clamp(item, min, max)); + return clampedValues.length === 2 ? [...clampedValues].sort((a, b) => a - b) : clampedValues; +}; + +const resolveThumbValues = ( + normalizedValue: number[] | undefined, + normalizedDefaultValue: number[] | undefined, + fallback: number[] +): number[] => { + if (normalizedValue !== undefined) { + return normalizedValue; + } + + if (normalizedDefaultValue !== undefined) { + return normalizedDefaultValue; + } + + return fallback; +}; + +const resolveThumbAriaLabelledBy = ({ + isRange, + ariaLabelledBy, + hiddenLabelId +}: { + isRange: boolean; + ariaLabelledBy?: string; + hiddenLabelId?: string; +}): string | undefined => { + if (isRange && ariaLabelledBy && hiddenLabelId) { + return `${ariaLabelledBy} ${hiddenLabelId}`; + } + + if (isRange) { + return undefined; + } + + return ariaLabelledBy; +}; + +const resolveThumbAriaLabel = ({ + isRange, + index, + rangeLabels, + ariaLabelledBy, + ariaLabel, + nativeAriaLabel +}: { + isRange: boolean; + index: number; + rangeLabels: [string, string]; + ariaLabelledBy?: string; + ariaLabel?: string; + nativeAriaLabel?: string; +}): string | undefined => { + if (isRange && ariaLabelledBy) { + return undefined; + } + + if (isRange) { + return rangeLabels[index]; + } + + if (ariaLabelledBy) { + return undefined; + } + + return ariaLabel ?? nativeAriaLabel ?? 'Slider value'; +}; + +export const useSlider = ({ + value, + defaultValue, + onValueChange, + onValueCommit, + min, + max, + step, + disabled = false, + size = 'md', + color = 'default', + rounded = 'full', + fullWidth = true, + ariaLabel, + 'aria-label': nativeAriaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + thumbLabels, + className, + ...restProps +}: SliderProps): UseSliderReturn => { + const resolvedMin = finite(min, 0); + const candidateStep = finite(step, 1); + const candidateMax = finite(max, 100); + const resolvedStep = candidateStep > 0 ? candidateStep : 1; + const resolvedMax = candidateMax > resolvedMin ? candidateMax : Math.max(100, resolvedMin + resolvedStep); + const singleFallback = [resolvedMin]; + const normalizedValue = normalizeSliderValues(value, singleFallback, resolvedMin, resolvedMax); + const normalizedDefaultValue = normalizeSliderValues( + defaultValue ?? (value === undefined ? singleFallback : undefined), + singleFallback, + resolvedMin, + resolvedMax + ); + const thumbValues = resolveThumbValues(normalizedValue, normalizedDefaultValue, singleFallback); + const isRange = thumbValues.length === 2; + const rangeLabels = thumbLabels ?? ['Minimum value', 'Maximum value']; + const rangeThumbLabelBaseId = useId(); + const thumbs = thumbValues.map((_, index) => { + const hiddenLabel = + isRange && ariaLabelledBy + ? { id: `${rangeThumbLabelBaseId}-thumb-${index}-label`, text: rangeLabels[index] } + : undefined; + + return { + key: `thumb-${index}`, + className: sliderThumbVariants({ rounded }), + visualClassName: sliderThumbVisualVariants({ color, rounded, size }), + 'aria-describedby': ariaDescribedBy, + 'aria-disabled': disabled ? true : undefined, + 'aria-labelledby': resolveThumbAriaLabelledBy({ isRange, ariaLabelledBy, hiddenLabelId: hiddenLabel?.id }), + 'aria-label': resolveThumbAriaLabel({ + isRange, + index, + rangeLabels, + ariaLabelledBy, + ariaLabel, + nativeAriaLabel + }), + hiddenLabel + }; + }); + + return { + rootProps: { + ...restProps, + value: normalizedValue, + defaultValue: normalizedValue === undefined ? normalizedDefaultValue : undefined, + onValueChange, + onValueCommit, + min: resolvedMin, + max: resolvedMax, + step: resolvedStep, + disabled, + dir: 'ltr', + orientation: 'horizontal', + className: cn(sliderRootVariants({ fullWidth }), className) + }, + trackClassName: sliderTrackVariants({ color, rounded, size }), + rangeClassName: sliderRangeVariants({ color, rounded }), + thumbs + }; +}; diff --git a/src/index.ts b/src/index.ts index 95e16845..55eca810 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { Input } from './components/atoms/input'; export { Link } from './components/atoms/link'; export { Progress } from './components/atoms/progress'; export { Skeleton } from './components/atoms/skeleton'; +export { Slider } from './components/atoms/slider'; export { Spacer } from './components/atoms/spacer'; export { Switch } from './components/atoms/switch'; export { Table } from './components/atoms/table';