feat(web): base-ui slider (#34064)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-03-25 16:03:49 +08:00
committed by GitHub
parent 1789988be7
commit a8e1ff85db
43 changed files with 425 additions and 1068 deletions

View File

@ -0,0 +1,73 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { Slider } from '../index'
describe('Slider', () => {
const getSliderInput = () => screen.getByLabelText('Value')
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '0')
expect(slider).toHaveAttribute('max', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should apply custom min, max, and step values', () => {
render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toHaveAttribute('min', '5')
expect(slider).toHaveAttribute('max', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
})
it('should clamp non-finite values to min', () => {
render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />)
expect(getSliderInput()).toHaveAttribute('aria-valuenow', '5')
})
it('should call onValueChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />)
const slider = getSliderInput()
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenLastCalledWith(21, expect.anything())
})
it('should not trigger onValueChange when disabled', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />)
const slider = getSliderInput()
expect(slider).toBeDisabled()
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onValueChange).not.toHaveBeenCalled()
})
it('should apply custom class names on root', () => {
const { container } = render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />)
const sliderWrapper = container.querySelector('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type * as React from 'react'
import { useState } from 'react'
import { Slider } from '.'
const meta = {
title: 'Base UI/Data Entry/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Single-value horizontal slider built on Base UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
},
min: {
control: 'number',
},
max: {
control: 'number',
},
step: {
control: 'number',
},
disabled: {
control: 'boolean',
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
function SliderDemo({
value: initialValue = 50,
defaultValue: _defaultValue,
...args
}: React.ComponentProps<typeof Slider>) {
const [value, setValue] = useState(initialValue)
return (
<div className="w-[320px] space-y-3">
<Slider
{...args}
value={value}
onValueChange={setValue}
aria-label="Demo slider"
/>
<div className="text-center text-text-secondary system-sm-medium">
{value}
</div>
</div>
)
}
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
},
}
export const Decimal: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
},
}
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}

View File

@ -0,0 +1,100 @@
'use client'
import { Slider as BaseSlider } from '@base-ui/react/slider'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type SliderRootProps = BaseSlider.Root.Props<number>
type SliderThumbProps = BaseSlider.Thumb.Props
type SliderBaseProps = Pick<
SliderRootProps,
'onValueChange' | 'min' | 'max' | 'step' | 'disabled' | 'name'
> & Pick<SliderThumbProps, 'aria-label' | 'aria-labelledby'> & {
className?: string
}
type ControlledSliderProps = SliderBaseProps & {
value: number
defaultValue?: never
}
type UncontrolledSliderProps = SliderBaseProps & {
value?: never
defaultValue?: number
}
export type SliderProps = ControlledSliderProps | UncontrolledSliderProps
const sliderRootClassName = 'group/slider relative inline-flex w-full data-[disabled]:opacity-30'
const sliderControlClassName = cn(
'relative flex h-5 w-full touch-none select-none items-center',
'data-[disabled]:cursor-not-allowed',
)
const sliderTrackClassName = cn(
'relative h-1 w-full overflow-hidden rounded-full',
'bg-[var(--slider-track,var(--color-components-slider-track))]',
)
const sliderIndicatorClassName = cn(
'h-full rounded-full',
'bg-[var(--slider-range,var(--color-components-slider-range))]',
)
const sliderThumbClassName = cn(
'block h-5 w-2 shrink-0 rounded-[3px] border-[0.5px]',
'border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
'bg-[var(--slider-knob,var(--color-components-slider-knob))] shadow-sm',
'transition-[background-color,border-color,box-shadow,opacity] motion-reduce:transition-none',
'hover:bg-[var(--slider-knob-hover,var(--color-components-slider-knob-hover))]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-slider-knob-border-hover focus-visible:ring-offset-0',
'active:shadow-md',
'group-data-[disabled]/slider:bg-[var(--slider-knob-disabled,var(--color-components-slider-knob-disabled))]',
'group-data-[disabled]/slider:border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
'group-data-[disabled]/slider:shadow-none',
)
const getSafeValue = (value: number | undefined, min: number) => {
if (value === undefined)
return undefined
return Number.isFinite(value) ? value : min
}
export function Slider({
value,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
name,
className,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
}: SliderProps) {
return (
<BaseSlider.Root
value={getSafeValue(value, min)}
defaultValue={getSafeValue(defaultValue, min)}
onValueChange={onValueChange}
min={min}
max={max}
step={step}
disabled={disabled}
name={name}
thumbAlignment="edge"
className={cn(sliderRootClassName, className)}
>
<BaseSlider.Control className={sliderControlClassName}>
<BaseSlider.Track className={sliderTrackClassName}>
<BaseSlider.Indicator className={sliderIndicatorClassName} />
</BaseSlider.Track>
<BaseSlider.Thumb
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
className={sliderThumbClassName}
/>
</BaseSlider.Control>
</BaseSlider.Root>
)
}