mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
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:
73
web/app/components/base/ui/slider/__tests__/index.spec.tsx
Normal file
73
web/app/components/base/ui/slider/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
92
web/app/components/base/ui/slider/index.stories.tsx
Normal file
92
web/app/components/base/ui/slider/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
100
web/app/components/base/ui/slider/index.tsx
Normal file
100
web/app/components/base/ui/slider/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user