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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 425 additions and 1068 deletions

View File

@ -7,7 +7,7 @@ import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
import commonEnUS from '../i18n/en-US/common.json'
import '../app/styles/globals.css'
import '../app/styles/markdown.scss'
import '../app/styles/markdown.css'
import './storybook.css'
const queryClient = new QueryClient({

View File

@ -12,15 +12,15 @@ vi.mock('ahooks', async (importOriginal) => {
}
})
vi.mock('react-slider', () => ({
default: (props: { className?: string, min?: number, max?: number, value: number, onChange: (value: number) => void }) => (
vi.mock('@/app/components/base/ui/slider', () => ({
Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => (
<input
type="range"
className={props.className}
className={`slider ${props.className ?? ''}`}
min={props.min}
max={props.max}
value={props.value}
onChange={e => props.onChange(Number(e.target.value))}
onChange={e => props.onValueChange(Number(e.target.value))}
/>
),
}))

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
import { Unblur } from '@/app/components/base/icons/src/vender/solid/education'
import Slider from '@/app/components/base/slider'
import { Slider } from '@/app/components/base/ui/slider'
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
import ItemPanel from './item-panel'
@ -105,12 +105,13 @@ const AgentSetting: FC<Props> = ({
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
value={tempPayload.max_iteration}
onChange={(value) => {
onValueChange={(value) => {
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })}
/>
<input

View File

@ -288,10 +288,8 @@ describe('ConfigContent', () => {
/>,
)
const weightedScoreSlider = screen.getAllByRole('slider')
.find(slider => slider.getAttribute('aria-valuemax') === '1')
expect(weightedScoreSlider).toBeDefined()
await user.click(weightedScoreSlider!)
const weightedScoreSlider = screen.getByLabelText('dataset.weightedScore.semantic')
weightedScoreSlider.focus()
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')

View File

@ -1,7 +0,0 @@
.weightedScoreSliderTrack {
background: var(--color-util-colors-blue-light-blue-light-500) !important;
}
.weightedScoreSliderTrack-1 {
background: transparent !important;
}

View File

@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event'
import WeightedScore from './weighted-score'
describe('WeightedScore', () => {
const getSliderInput = () => screen.getByLabelText('dataset.weightedScore.semantic')
beforeEach(() => {
vi.clearAllMocks()
})
@ -48,8 +50,8 @@ describe('WeightedScore', () => {
render(<WeightedScore value={value} onChange={onChange} />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
const slider = getSliderInput()
slider.focus()
expect(slider).toHaveFocus()
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
@ -69,9 +71,8 @@ describe('WeightedScore', () => {
render(<WeightedScore value={value} onChange={onChange} readonly />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
const slider = getSliderInput()
expect(slider).toBeDisabled()
await user.keyboard('{ArrowRight}')
// Assert

View File

@ -1,9 +1,13 @@
import type { CSSProperties } from 'react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'
import { cn } from '@/utils/classnames'
import './weighted-score.css'
import { Slider } from '@/app/components/base/ui/slider'
const weightedScoreSliderStyle: CSSProperties & Record<'--slider-track' | '--slider-range', string> = {
'--slider-track': 'var(--color-util-colors-teal-teal-500)',
'--slider-range': 'var(--color-util-colors-blue-light-blue-light-500)',
}
const formatNumber = (value: number) => {
if (value > 0 && value < 1)
@ -33,24 +37,26 @@ const WeightedScore = ({
return (
<div>
<div className="space-x-3 rounded-lg border border-components-panel-border px-3 pb-2 pt-5">
<Slider
className={cn('h-0.5 grow rounded-full !bg-util-colors-teal-teal-500')}
max={1.0}
min={0}
step={0.1}
value={value.value[0]}
onChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
trackClassName="weightedScoreSliderTrack"
disabled={readonly}
/>
<div className="grow" style={weightedScoreSliderStyle}>
<Slider
className="grow"
max={1.0}
min={0}
step={0.1}
value={value.value[0]}
onValueChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
disabled={readonly}
aria-label={t('weightedScore.semantic', { ns: 'dataset' })}
/>
</div>
<div className="mt-3 flex justify-between">
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500">
<div className="flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500 system-xs-semibold-uppercase">
<div className="mr-1 truncate uppercase" title={t('weightedScore.semantic', { ns: 'dataset' }) || ''}>
{t('weightedScore.semantic', { ns: 'dataset' })}
</div>
{formatNumber(value.value[0])}
</div>
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500">
<div className="flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500 system-xs-semibold-uppercase">
{formatNumber(value.value[1])}
<div className="ml-1 truncate uppercase" title={t('weightedScore.keyword', { ns: 'dataset' }) || ''}>
{t('weightedScore.keyword', { ns: 'dataset' })}

View File

@ -93,7 +93,6 @@ const ConfigParamModal: FC<Props> = ({
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
/* v8 ignore next -- callback dispatch depends on react-slider drag mechanics that are flaky in jsdom. @preserve */
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,

View File

@ -1,20 +1,9 @@
import { render, screen } from '@testing-library/react'
import ScoreSlider from '../index'
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
<input
type="range"
data-testid="slider"
value={value}
min={min}
max={max}
onChange={e => onChange(Number(e.target.value))}
/>
),
}))
describe('ScoreSlider', () => {
const getSliderInput = () => screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title')
beforeEach(() => {
vi.clearAllMocks()
})
@ -22,7 +11,7 @@ describe('ScoreSlider', () => {
it('should render the slider', () => {
render(<ScoreSlider value={90} onChange={vi.fn()} />)
expect(screen.getByTestId('slider')).toBeInTheDocument()
expect(getSliderInput()).toBeInTheDocument()
})
it('should display easy match and accurate match labels', () => {
@ -37,14 +26,14 @@ describe('ScoreSlider', () => {
it('should render with custom className', () => {
const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />)
// Verifying the component renders successfully with a custom className
expect(screen.getByTestId('slider')).toBeInTheDocument()
expect(getSliderInput()).toBeInTheDocument()
expect(container.firstChild).toHaveClass('custom-class')
})
it('should pass value to the slider', () => {
render(<ScoreSlider value={95} onChange={vi.fn()} />)
expect(screen.getByTestId('slider')).toHaveValue('95')
expect(getSliderInput()).toHaveValue('95')
expect(screen.getByText('0.95')).toBeInTheDocument()
})
})

View File

@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react'
import Slider from '../index'
describe('BaseSlider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the slider component', () => {
render(<Slider value={50} onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should display the formatted value in the thumb', () => {
render(<Slider value={85} onChange={vi.fn()} />)
expect(screen.getByText('0.85')).toBeInTheDocument()
})
it('should use default min/max/step when not provided', () => {
render(<Slider value={50} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '50')
})
it('should use custom min/max/step when provided', () => {
render(<Slider value={90} min={80} max={100} step={5} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '80')
expect(slider).toHaveAttribute('aria-valuemax', '100')
expect(slider).toHaveAttribute('aria-valuenow', '90')
})
it('should handle NaN value as 0', () => {
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
})
it('should pass disabled prop', () => {
render(<Slider value={50} disabled onChange={vi.fn()} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@ -1,40 +0,0 @@
import ReactSlider from 'react-slider'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
type ISliderProps = {
className?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
return (
<ReactSlider
disabled={disabled}
value={isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn(className, s.slider)}
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] h-[18px] w-2 cursor-pointer rounded-[36px] border !border-black/8 bg-white shadow-md')}
trackClassName={s['slider-track']}
onChange={onChange}
renderThumb={(props, state) => (
<div {...props}>
<div className="relative h-full w-full">
<div className="absolute left-[50%] top-[-16px] translate-x-[-50%] text-text-primary system-sm-semibold">
{(state.valueNow / 100).toFixed(2)}
</div>
</div>
</div>
)}
/>
)
}
export default Slider

View File

@ -1,20 +0,0 @@
.slider {
position: relative;
}
.slider.disabled {
opacity: 0.6;
}
.slider-thumb:focus {
outline: none;
}
.slider-track {
background-color: #528BFF;
height: 2px;
}
.slider-track-1 {
background-color: #E5E7EB;
}

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider'
import { Slider } from '@/app/components/base/ui/slider'
type Props = {
className?: string
@ -10,23 +10,42 @@ type Props = {
onChange: (value: number) => void
}
const clamp = (value: number, min: number, max: number) => {
if (!Number.isFinite(value))
return min
return Math.min(Math.max(value, min), max)
}
const ScoreSlider: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
const safeValue = clamp(value, 80, 100)
return (
<div className={className}>
<div className="mt-[14px] h-px">
<div className="relative mt-[14px]">
<Slider
max={100}
className="w-full"
value={safeValue}
min={80}
max={100}
step={1}
value={value}
onChange={onChange}
onValueChange={onChange}
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
/>
<div
className="pointer-events-none absolute top-[-16px] text-text-primary system-sm-semibold"
style={{
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
transform: 'translateX(-50%)',
}}
>
{(safeValue / 100).toFixed(2)}
</div>
</div>
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">

View File

@ -14,12 +14,14 @@ describe('ParamItem Slider onChange', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
it('should divide slider value by 100 when max < 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={0.5} min={0} max={1} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
await user.click(slider)
slider.focus()
await user.keyboard('{ArrowRight}')
// max=1 < 5, so slider value change (50->51) becomes 0.51
@ -29,9 +31,9 @@ describe('ParamItem Slider onChange', () => {
it('should not divide slider value when max >= 5', async () => {
const user = userEvent.setup()
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
await user.click(slider)
slider.focus()
await user.keyboard('{ArrowRight}')
// max=10 >= 5, so value remains raw (5->6)

View File

@ -17,6 +17,8 @@ describe('ParamItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('Test Param')
describe('Rendering', () => {
it('should render the parameter name', () => {
render(<ParamItem {...defaultProps} />)
@ -54,7 +56,7 @@ describe('ParamItem', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
})
})
@ -74,7 +76,7 @@ describe('ParamItem', () => {
it('should disable Slider when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />)
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
expect(getSlider()).toBeDisabled()
})
it('should set switch value based on enable prop', () => {
@ -135,7 +137,7 @@ describe('ParamItem', () => {
await user.clear(input)
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
expect(getSlider()).toHaveAttribute('aria-valuenow', '0')
await user.tab()
@ -166,12 +168,12 @@ describe('ParamItem', () => {
await user.type(input, '1.5')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100')
expect(getSlider()).toHaveAttribute('aria-valuenow', '100')
})
it('should pass scaled value to slider when max < 5', () => {
render(<ParamItem {...defaultProps} value={0.5} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
// When max < 5, slider value = value * 100 = 50
expect(slider).toHaveAttribute('aria-valuenow', '50')
@ -179,7 +181,7 @@ describe('ParamItem', () => {
it('should pass raw value to slider when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} max={10} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
// When max >= 5, slider value = value = 5
expect(slider).toHaveAttribute('aria-valuenow', '5')
@ -212,15 +214,15 @@ describe('ParamItem', () => {
render(<ParamItem {...defaultProps} value={0.5} min={0} />)
// Slider should get value * 100 = 50, min * 100 = 0, max * 100 = 100
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '100')
const slider = getSlider()
expect(slider).toHaveAttribute('max', '100')
})
it('should not scale slider value when max >= 5', () => {
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '10')
const slider = getSlider()
expect(slider).toHaveAttribute('max', '10')
})
it('should expose default minimum of 0 when min is not provided', () => {

View File

@ -14,6 +14,8 @@ describe('ScoreThresholdItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold')
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<ScoreThresholdItem {...defaultProps} />)
@ -32,7 +34,7 @@ describe('ScoreThresholdItem', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
})
})
@ -63,7 +65,7 @@ describe('ScoreThresholdItem', () => {
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
expect(getSlider()).toBeDisabled()
})
})

View File

@ -19,6 +19,8 @@ describe('TopKItem', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k')
describe('Rendering', () => {
it('should render the translated parameter name', () => {
render(<TopKItem {...defaultProps} />)
@ -37,7 +39,7 @@ describe('TopKItem', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
})
})
@ -52,7 +54,7 @@ describe('TopKItem', () => {
render(<TopKItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
expect(getSlider()).toBeDisabled()
})
})
@ -77,10 +79,10 @@ describe('TopKItem', () => {
it('should render slider with max >= 5 so no scaling is applied', () => {
render(<TopKItem {...defaultProps} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
// max=10 >= 5 so slider shows raw values
expect(slider).toHaveAttribute('aria-valuemax', '10')
expect(slider).toHaveAttribute('max', '10')
})
it('should not render a switch (no hasSwitch prop)', () => {
@ -116,9 +118,9 @@ describe('TopKItem', () => {
it('should call onChange with integer value when slider changes', async () => {
const user = userEvent.setup()
render(<TopKItem {...defaultProps} value={2} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
await user.click(slider)
slider.focus()
await user.keyboard('{ArrowRight}')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3)

View File

@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import { Slider } from '@/app/components/base/ui/slider'
import {
NumberField,
NumberFieldControls,
@ -78,7 +78,8 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
value={max < 5 ? value * 100 : value}
min={min < 1 ? min * 100 : min}
max={max < 5 ? max * 100 : max}
onChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
onValueChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
aria-label={name}
/>
</div>
</div>

View File

@ -1,77 +0,0 @@
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 Component', () => {
it('should render with correct default ARIA limits and current value', () => {
render(<Slider value={50} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
expect(slider).toHaveAttribute('aria-valuemax', '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} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '5')
expect(slider).toHaveAttribute('aria-valuemax', '20')
expect(slider).toHaveAttribute('aria-valuenow', '10')
})
it('should default to 0 if the value prop is NaN', () => {
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '0')
})
it('should call onChange when arrow keys are pressed', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} />)
const slider = screen.getByRole('slider')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(21, 0)
})
it('should not trigger onChange when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Slider value={20} onChange={onChange} disabled />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-disabled', 'true')
await act(async () => {
slider.focus()
await user.keyboard('{ArrowRight}')
})
expect(onChange).not.toHaveBeenCalled()
})
it('should apply custom class names', () => {
render(
<Slider value={10} onChange={vi.fn()} className="outer-test" thumbClassName="thumb-test" />,
)
const sliderWrapper = screen.getByRole('slider').closest('.outer-test')
expect(sliderWrapper).toBeInTheDocument()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveClass('thumb-test')
})
})

View File

@ -1,635 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Data Entry/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current slider value',
},
min: {
control: 'number',
description: 'Minimum value (default: 0)',
},
max: {
control: 'number',
description: 'Maximum value (default: 100)',
},
step: {
control: 'number',
description: 'Step increment (default: 1)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
args: {
onChange: (value) => {
console.log('Slider value:', value)
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SliderDemo = (args: any) => {
const [value, setValue] = useState(args.value || 50)
return (
<div style={{ width: '400px' }}>
<Slider
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Slider value:', v)
}}
/>
<div className="mt-4 text-center text-sm text-gray-600">
Value:
{' '}
<span className="text-lg font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}
// With custom range
export const CustomRange: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 25,
min: 0,
max: 50,
step: 1,
disabled: false,
},
}
// With step increment
export const WithStepIncrement: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 10,
disabled: false,
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 2.5,
min: 0,
max: 5,
step: 0.5,
disabled: false,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}
// Real-world example - Volume control
const VolumeControlDemo = () => {
const [volume, setVolume] = useState(70)
const getVolumeIcon = (vol: number) => {
if (vol === 0)
return '🔇'
if (vol < 33)
return '🔈'
if (vol < 66)
return '🔉'
return '🔊'
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Volume Control</h3>
<span className="text-2xl">{getVolumeIcon(volume)}</span>
</div>
<Slider
value={volume}
min={0}
max={100}
step={1}
onChange={setVolume}
/>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>Mute</span>
<span className="text-lg font-semibold">
{volume}
%
</span>
<span>Max</span>
</div>
</div>
)
}
export const VolumeControl: Story = {
render: () => <VolumeControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Brightness control
const BrightnessControlDemo = () => {
const [brightness, setBrightness] = useState(80)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Screen Brightness</h3>
<span className="text-2xl"></span>
</div>
<Slider
value={brightness}
min={0}
max={100}
step={5}
onChange={setBrightness}
/>
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
<div className="text-sm text-gray-700">
Preview at
{' '}
{brightness}
% brightness
</div>
</div>
</div>
)
}
export const BrightnessControl: Story = {
render: () => <BrightnessControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Price range filter
const PriceRangeFilterDemo = () => {
const [maxPrice, setMaxPrice] = useState(500)
const minPrice = 0
const products = [
{ name: 'Product A', price: 150 },
{ name: 'Product B', price: 350 },
{ name: 'Product C', price: 600 },
{ name: 'Product D', price: 250 },
{ name: 'Product E', price: 450 },
]
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
<div className="mb-2">
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
<span>Maximum Price</span>
<span className="font-semibold text-gray-900">
$
{maxPrice}
</span>
</div>
<Slider
value={maxPrice}
min={0}
max={1000}
step={50}
onChange={setMaxPrice}
/>
</div>
<div className="mt-6">
<div className="mb-3 text-sm font-medium text-gray-700">
Showing
{' '}
{filteredProducts.length}
{' '}
of
{' '}
{products.length}
{' '}
products
</div>
<div className="space-y-2">
{filteredProducts.map(product => (
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<span className="text-sm">{product.name}</span>
<span className="font-semibold text-gray-900">
$
{product.price}
</span>
</div>
))}
</div>
</div>
</div>
)
}
export const PriceRangeFilter: Story = {
render: () => <PriceRangeFilterDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Temperature selector
const TemperatureSelectorDemo = () => {
const [temperature, setTemperature] = useState(22)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
<div className="mb-6">
<Slider
value={temperature}
min={16}
max={30}
step={0.5}
onChange={setTemperature}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-blue-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Celsius</div>
<div className="text-3xl font-bold text-blue-600">
{temperature}
°C
</div>
</div>
<div className="rounded-lg bg-orange-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
<div className="text-3xl font-bold text-orange-600">
{fahrenheit}
°F
</div>
</div>
</div>
<div className="mt-4 text-center text-xs text-gray-500">
{temperature < 18 && '🥶 Too cold'}
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
{temperature > 24 && '🥵 Too warm'}
</div>
</div>
)
}
export const TemperatureSelector: Story = {
render: () => <TemperatureSelectorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Progress/completion slider
const ProgressSliderDemo = () => {
const [progress, setProgress] = useState(65)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
<Slider
value={progress}
min={0}
max={100}
step={5}
onChange={setProgress}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-lg font-bold text-blue-600">
{progress}
%
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
<span className="text-xs text-gray-500">25%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
<span className="text-xs text-gray-500">50%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
<span className="text-xs text-gray-500">75%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
<span className="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
</div>
)
}
export const ProgressSlider: Story = {
render: () => <ProgressSliderDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Zoom control
const ZoomControlDemo = () => {
const [zoom, setZoom] = useState(100)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
<div className="flex items-center gap-4">
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.max(50, zoom - 10))}
>
-
</button>
<div className="flex-1">
<Slider
value={zoom}
min={50}
max={200}
step={10}
onChange={setZoom}
/>
</div>
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.min(200, zoom + 10))}
>
+
</button>
</div>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>50%</span>
<span className="text-lg font-semibold">
{zoom}
%
</span>
<span>200%</span>
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
<div className="text-sm">Preview content</div>
</div>
</div>
)
}
export const ZoomControl: Story = {
render: () => <ZoomControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - AI model parameters
const AIModelParametersDemo = () => {
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [topP, setTopP] = useState(0.9)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
<div className="space-y-6">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Temperature</label>
<span className="text-sm font-semibold">{temperature}</span>
</div>
<Slider
value={temperature}
min={0}
max={2}
step={0.1}
onChange={setTemperature}
/>
<p className="mt-1 text-xs text-gray-500">
Controls randomness. Lower is more focused, higher is more creative.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
<span className="text-sm font-semibold">{maxTokens}</span>
</div>
<Slider
value={maxTokens}
min={100}
max={4000}
step={100}
onChange={setMaxTokens}
/>
<p className="mt-1 text-xs text-gray-500">
Maximum length of generated response.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Top P</label>
<span className="text-sm font-semibold">{topP}</span>
</div>
<Slider
value={topP}
min={0}
max={1}
step={0.05}
onChange={setTopP}
/>
<p className="mt-1 text-xs text-gray-500">
Nucleus sampling threshold.
</p>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
<div>
<strong>Temperature:</strong>
{' '}
{temperature}
</div>
<div>
<strong>Max Tokens:</strong>
{' '}
{maxTokens}
</div>
<div>
<strong>Top P:</strong>
{' '}
{topP}
</div>
</div>
</div>
)
}
export const AIModelParameters: Story = {
render: () => <AIModelParametersDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Image quality selector
const ImageQualitySelectorDemo = () => {
const [quality, setQuality] = useState(80)
const getQualityLabel = (q: number) => {
if (q < 50)
return 'Low'
if (q < 70)
return 'Medium'
if (q < 90)
return 'High'
return 'Maximum'
}
const estimatedSize = Math.round((quality / 100) * 5)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
<Slider
value={quality}
min={10}
max={100}
step={10}
onChange={setQuality}
/>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">Quality</div>
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
<div className="text-xs text-gray-500">
{quality}
%
</div>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">File Size</div>
<div className="text-lg font-semibold">
~
{estimatedSize}
{' '}
MB
</div>
<div className="text-xs text-gray-500">Estimated</div>
</div>
</div>
</div>
)
}
export const ImageQualitySelector: Story = {
render: () => <ImageQualitySelectorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Multiple sliders
const MultipleSlidersDemo = () => {
const [red, setRed] = useState(128)
const [green, setGreen] = useState(128)
const [blue, setBlue] = useState(128)
const rgbColor = `rgb(${red}, ${green}, ${blue})`
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-red-600">Red</label>
<span className="text-sm font-semibold">{red}</span>
</div>
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-green-600">Green</label>
<span className="text-sm font-semibold">{green}</span>
</div>
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-blue-600">Blue</label>
<span className="text-sm font-semibold">{blue}</span>
</div>
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div
className="h-24 w-24 rounded-lg border-2 border-gray-300"
style={{ backgroundColor: rgbColor }}
/>
<div className="text-right">
<div className="mb-1 text-xs text-gray-600">Color Value</div>
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
<div className="mt-1 font-mono text-xs text-gray-500">
#
{red.toString(16).padStart(2, '0')}
{green.toString(16).padStart(2, '0')}
{blue.toString(16).padStart(2, '0')}
</div>
</div>
</div>
</div>
)
}
export const MultipleSliders: Story = {
render: () => <MultipleSlidersDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}

View File

@ -1,43 +0,0 @@
import ReactSlider from 'react-slider'
import { cn } from '@/utils/classnames'
import './style.css'
type ISliderProps = {
className?: string
thumbClassName?: string
trackClassName?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({
className,
thumbClassName,
trackClassName,
max,
min,
step,
value,
disabled,
onChange,
}) => {
return (
<ReactSlider
disabled={disabled}
value={Number.isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn('slider relative', className)}
thumbClassName={cn('absolute top-[-9px] h-5 w-2 rounded-[3px] border-[0.5px] border-components-slider-knob-border bg-components-slider-knob shadow-sm focus:outline-none', !disabled && 'cursor-pointer', thumbClassName)}
trackClassName={cn('h-0.5 rounded-full', 'slider-track', trackClassName)}
onChange={onChange}
/>
)
}
export default Slider

View File

@ -1,11 +0,0 @@
.slider.disabled {
opacity: 0.6;
}
.slider-track {
background-color: var(--color-components-slider-range);
}
.slider-track-1 {
background-color: var(--color-components-slider-track);
}

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>
)
}

View File

@ -14,6 +14,8 @@ describe('IndexMethod', () => {
vi.clearAllMocks()
})
const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndexMethod {...defaultProps} />)
@ -123,8 +125,7 @@ describe('IndexMethod', () => {
describe('KeywordNumber', () => {
it('should render KeywordNumber component inside Economy option', () => {
render(<IndexMethod {...defaultProps} />)
// KeywordNumber has a slider
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(getKeywordSlider()).toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {

View File

@ -11,6 +11,8 @@ describe('KeyWordNumber', () => {
vi.clearAllMocks()
})
const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
describe('Rendering', () => {
it('should render without crashing', () => {
render(<KeyWordNumber {...defaultProps} />)
@ -31,8 +33,7 @@ describe('KeyWordNumber', () => {
it('should render slider', () => {
render(<KeyWordNumber {...defaultProps} />)
// Slider has a slider role
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(getSlider()).toBeInTheDocument()
})
it('should render input number field', () => {
@ -61,7 +62,7 @@ describe('KeyWordNumber', () => {
it('should pass correct value to slider', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
expect(slider).toHaveAttribute('aria-valuenow', '30')
})
})
@ -71,8 +72,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const slider = screen.getByRole('slider')
// Verify slider is rendered and interactive
const slider = getSlider()
expect(slider).toBeInTheDocument()
expect(slider).not.toBeDisabled()
})
@ -109,14 +109,14 @@ describe('KeyWordNumber', () => {
describe('Slider Configuration', () => {
it('should have max value of 50', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '50')
const slider = getSlider()
expect(slider).toHaveAttribute('max', '50')
})
it('should have min value of 0', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
const slider = getSlider()
expect(slider).toHaveAttribute('min', '0')
})
})
@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
describe('Accessibility', () => {
it('should have accessible slider', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
const slider = getSlider()
expect(slider).toBeInTheDocument()
})

View File

@ -1,7 +1,6 @@
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
@ -11,6 +10,7 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { Slider } from '@/app/components/base/ui/slider'
const MIN_KEYWORD_NUMBER = 0
const MAX_KEYWORD_NUMBER = 50
@ -47,7 +47,8 @@ const KeyWordNumber = ({
value={keywordNumber}
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
onChange={onKeywordNumberChange}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
/>
<NumberField
className="w-12 shrink-0"

View File

@ -11,9 +11,9 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ onChange }: { onChange: (v: number) => void }) => (
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
vi.mock('@/app/components/base/ui/slider', () => ({
Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => (
<button onClick={() => onValueChange(2)} data-testid="slider-btn">Slide 2</button>
),
}))

View File

@ -7,10 +7,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Slider } from '@/app/components/base/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
@ -78,6 +78,7 @@ function ParameterItem({
}
const renderValue = value ?? localValue ?? getDefaultValue()
const sliderLabel = parameterRule.label[language] || parameterRule.label.en_US
const handleInputChange = (newValue: ParameterValue) => {
setLocalValue(newValue)
@ -170,7 +171,8 @@ function ParameterItem({
min={parameterRule.min}
max={parameterRule.max}
step={step}
onChange={handleSlideChange}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
)}
<input
@ -197,7 +199,8 @@ function ParameterItem({
min={parameterRule.min}
max={parameterRule.max}
step={0.1}
onChange={handleSlideChange}
onValueChange={handleSlideChange}
aria-label={sliderLabel}
/>
)}
<input
@ -337,9 +340,9 @@ function ParameterItem({
}
<div
className="mr-0.5 truncate text-text-secondary system-xs-regular"
title={parameterRule.label[language] || parameterRule.label.en_US}
title={sliderLabel}
>
{parameterRule.label[language] || parameterRule.label.en_US}
{sliderLabel}
</div>
{
parameterRule.help && (

View File

@ -145,7 +145,7 @@ describe('AgentStrategy', () => {
/>,
)
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByLabelText('Count')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})

View File

@ -9,7 +9,6 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import ListEmpty from '@/app/components/base/list-empty'
import Slider from '@/app/components/base/slider'
import {
NumberField,
NumberFieldControls,
@ -18,6 +17,7 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { Slider } from '@/app/components/base/ui/slider'
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
@ -147,10 +147,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
<div className="flex w-[200px] items-center gap-3">
<Slider
value={value}
onChange={onChange}
onValueChange={onChange}
className="w-full"
min={def.min}
max={def.max}
aria-label={renderI18nObject(def.label)}
/>
<NumberField
value={value}

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import * as React from 'react'
import { useCallback } from 'react'
import Slider from '@/app/components/base/slider'
import { Slider } from '@/app/components/base/ui/slider'
export type InputNumberWithSliderProps = {
value: number
@ -22,7 +22,7 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
onChange,
}) => {
const handleBlur = useCallback(() => {
if (value === undefined || value === null) {
if (value === undefined || value === null || Number.isNaN(value)) {
onChange(defaultValue)
return
}
@ -57,8 +57,9 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
min={min}
max={max}
step={1}
onChange={onChange}
onValueChange={onChange}
disabled={readonly}
aria-label="Number input slider"
/>
</div>
)

View File

@ -6,8 +6,8 @@ import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { cn } from '@/utils/classnames'
import { MemoryRole } from '../../../types'
@ -154,7 +154,7 @@ const MemoryConfig: FC<Props> = ({
size="md"
disabled={readonly}
/>
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
<div className="text-text-tertiary system-xs-medium-uppercase">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
</div>
<div className="flex h-8 items-center space-x-2">
<Slider
@ -163,8 +163,9 @@ const MemoryConfig: FC<Props> = ({
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={handleWindowSizeChange}
onValueChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
aria-label={t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
/>
<Input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}

View File

@ -3,8 +3,8 @@ import type {
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { useRetryConfig } from './hooks'
import s from './style.module.css'
@ -70,9 +70,10 @@ const RetryOnPanel = ({
<Slider
className="mr-3 w-[108px]"
value={retry_config?.max_retries || 3}
onChange={handleMaxRetriesChange}
onValueChange={handleMaxRetriesChange}
min={1}
max={10}
aria-label={t('nodes.common.retry.maxRetries', { ns: 'workflow' })}
/>
<Input
type="number"
@ -91,9 +92,10 @@ const RetryOnPanel = ({
<Slider
className="mr-3 w-[108px]"
value={retry_config?.retry_interval || 1000}
onChange={handleRetryIntervalChange}
onValueChange={handleRetryIntervalChange}
min={100}
max={5000}
aria-label={t('nodes.common.retry.retryInterval', { ns: 'workflow' })}
/>
<Input
type="number"

View File

@ -5,8 +5,8 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import { Slider } from '@/app/components/base/ui/slider'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { MAX_PARALLEL_LIMIT } from '@/config'
@ -57,7 +57,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
title={t(`${i18nPrefix}.input`, { ns: 'workflow' })}
required
operations={(
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
)}
>
<VarReferencePicker
@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
title={t(`${i18nPrefix}.output`, { ns: 'workflow' })}
required
operations={(
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
)}
>
<VarReferencePicker
@ -103,10 +103,11 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
<Input type="number" wrapperClassName="w-18 mr-4 " max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} />
<Slider
value={inputs.parallel_nums}
onChange={changeParallelNums}
onValueChange={changeParallelNums}
max={MAX_PARALLEL_LIMIT}
min={MIN_ITERATION_PARALLEL_NUM}
className=" mt-4 flex-1 shrink-0"
className="mt-4 flex-1 shrink-0"
aria-label={t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })}
/>
</div>

View File

@ -39,7 +39,7 @@ describe('IndexMethod', () => {
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
expect(onKeywordNumberChange).toHaveBeenCalledWith(7, expect.anything())
})
it('should disable keyword controls when readonly is enabled', () => {

View File

@ -9,8 +9,8 @@ import {
HighQuality,
} from '@/app/components/base/icons/src/vender/knowledge'
import Input from '@/app/components/base/input'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
import { Slider } from '@/app/components/base/ui/slider'
import { Field } from '@/app/components/workflow/nodes/_base/components/layout'
import { cn } from '@/utils/classnames'
import {
@ -94,7 +94,7 @@ const IndexMethod = ({
>
<div className="flex items-center">
<div className="flex grow items-center">
<div className="system-xs-medium truncate text-text-secondary">
<div className="truncate text-text-secondary system-xs-medium">
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
</div>
<Tooltip
@ -107,7 +107,8 @@ const IndexMethod = ({
disabled={readonly}
className="mr-3 w-24 shrink-0"
value={keywordNumber}
onChange={onKeywordNumberChange}
onValueChange={onKeywordNumberChange}
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
/>
<Input
disabled={readonly}

View File

@ -93,11 +93,11 @@ describe('trigger-schedule components', () => {
const onChange = vi.fn()
render(<OnMinuteSelector value={15} onChange={onChange} />)
const slider = screen.getByRole('slider')
const slider = screen.getByLabelText('workflow.nodes.triggerSchedule.onMinute')
slider.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(16, 0)
expect(onChange).toHaveBeenCalledWith(16, expect.objectContaining({ activeThumbIndex: 0 }))
})
it('should keep at least one weekday selected', async () => {

View File

@ -1,6 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'
import { Slider } from '@/app/components/base/ui/slider'
type OnMinuteSelectorProps = {
value?: number
@ -27,7 +27,8 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
min={0}
max={59}
step={1}
onChange={onChange}
onValueChange={onChange}
aria-label={t('nodes.triggerSchedule.onMinute', { ns: 'workflow' })}
/>
</div>
</div>

View File

@ -965,11 +965,6 @@
"count": 1
}
},
"app/components/app/configuration/dataset-config/params-config/weighted-score.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -2043,11 +2038,6 @@
"count": 3
}
},
"app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
}
},
"app/components/base/features/new-feature-panel/annotation-reply/type.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -2878,19 +2868,6 @@
"count": 3
}
},
"app/components/base/slider/index.stories.tsx": {
"no-console": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/slider/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/base/sort/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
@ -7041,9 +7018,6 @@
}
},
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
@ -7872,12 +7846,6 @@
"app/components/workflow/nodes/iteration/panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/workflow/nodes/iteration/use-config.ts": {
@ -7906,9 +7874,6 @@
"app/components/workflow/nodes/knowledge-base/components/index-method.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/workflow/nodes/knowledge-base/components/option-card.tsx": {

View File

@ -140,7 +140,6 @@
"react-multi-email": "1.0.25",
"react-papaparse": "4.4.0",
"react-pdf-highlighter": "8.0.0-rc.0",
"react-slider": "2.0.6",
"react-sortablejs": "6.1.4",
"react-syntax-highlighter": "15.6.6",
"react-textarea-autosize": "8.5.9",
@ -202,7 +201,6 @@
"@types/qs": "6.15.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-slider": "1.3.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-window": "1.8.8",
"@types/sortablejs": "1.15.9",

23
web/pnpm-lock.yaml generated
View File

@ -307,9 +307,6 @@ importers:
react-pdf-highlighter:
specifier: 8.0.0-rc.0
version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-slider:
specifier: 2.0.6
version: 2.0.6(react@19.2.4)
react-sortablejs:
specifier: 6.1.4
version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7)
@ -488,9 +485,6 @@ importers:
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/react-slider':
specifier: 1.3.6
version: 1.3.6
'@types/react-syntax-highlighter':
specifier: 15.5.13
version: 15.5.13
@ -3537,9 +3531,6 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-slider@1.3.6':
resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@ -6885,11 +6876,6 @@ packages:
react-dom: ^19.2.4
webpack: ^5.59.0
react-slider@2.0.6:
resolution: {integrity: sha512-gJxG1HwmuMTJ+oWIRCmVWvgwotNCbByTwRkFZC6U4MBsHqJBmxwbYRJUmxy4Tke1ef8r9jfXjgkmY/uHOCEvbA==}
peerDependencies:
react: ^16 || ^17 || ^18
react-sortablejs@6.1.4:
resolution: {integrity: sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==}
peerDependencies:
@ -10939,10 +10925,6 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-slider@1.3.6':
dependencies:
'@types/react': 19.2.14
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 19.2.14
@ -14937,11 +14919,6 @@ snapshots:
webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)
webpack-sources: 3.3.4
react-slider@2.0.6(react@19.2.4):
dependencies:
prop-types: 15.8.1
react: 19.2.4
react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7):
dependencies:
'@types/sortablejs': 1.15.9