refactor(web): number inputs to use Base UI NumberField (#33539)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-03-17 18:45:02 +08:00 committed by GitHub
parent d1961c261e
commit 3db1ba36e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1340 additions and 1273 deletions

View File

@ -180,7 +180,7 @@ describe('dataset-config/params-config', () => {
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
await user.click(incrementButtons[0])
await waitFor(() => {
@ -213,7 +213,7 @@ describe('dataset-config/params-config', () => {
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
await user.click(incrementButtons[0])
await waitFor(() => {

View File

@ -212,7 +212,7 @@ describe('RetrievalSection', () => {
currentDataset={dataset}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
await userEvent.click(topKIncrement)
// Assert
@ -267,7 +267,7 @@ describe('RetrievalSection', () => {
docLink={path => path || ''}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
await userEvent.click(topKIncrement)
// Assert

View File

@ -27,7 +27,21 @@ describe('NumberInputField', () => {
it('should update value when users click increment', () => {
render(<NumberInputField label="Count" />)
fireEvent.click(screen.getByRole('button', { name: 'increment' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.increment' }))
expect(mockField.handleChange).toHaveBeenCalledWith(3)
})
it('should reset field value when users clear the input', () => {
render(<NumberInputField label="Count" />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
expect(mockField.handleChange).toHaveBeenCalledWith(0)
})
it('should clamp out-of-range edits before updating field state', () => {
render(<NumberInputField label="Count" min={0} max={10} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } })
expect(mockField.handleChange).toHaveBeenLastCalledWith(10)
})
})

View File

@ -1,24 +1,52 @@
import type { InputNumberProps } from '../../../input-number'
import type { ReactNode } from 'react'
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '../../../ui/number-field'
import type { LabelProps } from '../label'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import { useFieldContext } from '../..'
import { InputNumber } from '../../../input-number'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '../../../ui/number-field'
import Label from '../label'
type TextFieldProps = {
type NumberInputFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
inputClassName?: string
unit?: ReactNode
size?: NumberFieldSize
} & Omit<NumberFieldRootProps, 'children' | 'className' | 'id' | 'value' | 'defaultValue' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onBlur' | 'className' | 'onChange'>
const NumberInputField = ({
label,
labelOptions,
className,
...inputProps
}: TextFieldProps) => {
inputClassName,
unit,
size = 'regular',
...props
}: NumberInputFieldProps) => {
const field = useFieldContext<number>()
const {
value: _value,
min,
max,
step,
disabled,
readOnly,
required,
name: _name,
id: _id,
...inputProps
} = props
const emptyValue = min ?? 0
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
@ -27,13 +55,36 @@ const NumberInputField = ({
label={label}
{...(labelOptions ?? {})}
/>
<InputNumber
<NumberField
id={field.name}
name={field.name}
value={field.state.value}
onChange={value => field.handleChange(value)}
onBlur={field.handleBlur}
{...inputProps}
/>
min={min}
max={max}
step={step}
disabled={disabled}
readOnly={readOnly}
required={required}
onValueChange={value => field.handleChange(value ?? emptyValue)}
>
<NumberFieldGroup size={size}>
<NumberFieldInput
{...inputProps}
size={size}
className={inputClassName}
onBlur={field.handleBlur}
/>
{Boolean(unit) && (
<NumberFieldUnit size={size}>
{unit}
</NumberFieldUnit>
)}
<NumberFieldControls>
<NumberFieldIncrement size={size} />
<NumberFieldDecrement size={size} />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
)
}

View File

@ -1,369 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputNumber } from '../index'
describe('InputNumber Component', () => {
const defaultProps = {
onChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders input with default values', () => {
render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('handles increment button click', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).toHaveBeenCalledWith(6)
})
it('handles decrement button click', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).toHaveBeenCalledWith(4)
})
it('respects max value constraint', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={10} max={10} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('respects min value constraint', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={0} min={0} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('handles direct input changes', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42)
})
it('handles empty input', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={1} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('does not call onChange when input is not parseable', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).not.toHaveBeenCalled()
})
it('does not call onChange when direct input exceeds range', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} min={0} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '11' } })
expect(onChange).not.toHaveBeenCalled()
})
it('uses default value when increment and decrement are clicked without value prop', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} defaultValue={7} />)
await user.click(screen.getByRole('button', { name: /increment/i }))
await user.click(screen.getByRole('button', { name: /decrement/i }))
expect(onChange).toHaveBeenNthCalledWith(1, 7)
expect(onChange).toHaveBeenNthCalledWith(2, 7)
})
it('falls back to zero when controls are used without value and defaultValue', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} />)
await user.click(screen.getByRole('button', { name: /increment/i }))
await user.click(screen.getByRole('button', { name: /decrement/i }))
expect(onChange).toHaveBeenNthCalledWith(1, 0)
expect(onChange).toHaveBeenNthCalledWith(2, 0)
})
it('displays unit when provided', () => {
const onChange = vi.fn()
const unit = 'px'
render(<InputNumber onChange={onChange} unit={unit} />)
expect(screen.getByText(unit)).toBeInTheDocument()
})
it('disables controls when disabled prop is true', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} disabled />)
const input = screen.getByRole('textbox')
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
expect(input).toBeDisabled()
expect(incrementBtn).toBeDisabled()
expect(decrementBtn).toBeDisabled()
})
it('does not change value when disabled controls are clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { getByRole } = render(<InputNumber onChange={onChange} disabled value={5} />)
const incrementBtn = getByRole('button', { name: /increment/i })
const decrementBtn = getByRole('button', { name: /decrement/i })
expect(incrementBtn).toBeDisabled()
expect(decrementBtn).toBeDisabled()
await user.click(incrementBtn)
await user.click(decrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('keeps increment guard when disabled even if button is force-clickable', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} disabled value={5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
// Remove native disabled to force event dispatch and hit component-level guard.
incrementBtn.removeAttribute('disabled')
fireEvent.click(incrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('keeps decrement guard when disabled even if button is force-clickable', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} disabled value={5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
// Remove native disabled to force event dispatch and hit component-level guard.
decrementBtn.removeAttribute('disabled')
fireEvent.click(decrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('applies large-size classes for control buttons', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} size="large" />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
expect(incrementBtn).toHaveClass('pt-1.5')
expect(decrementBtn).toHaveClass('pb-1.5')
})
it('prevents increment beyond max with custom amount', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={8} max={10} amount={5} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('uses fallback step guard when step is any', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('prevents decrement below min with custom amount', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={2} min={0} amount={5} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('increments when value with custom amount stays within bounds', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={5} max={10} amount={3} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).toHaveBeenCalledWith(8)
})
it('decrements when value with custom amount stays within bounds', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={5} min={0} amount={3} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).toHaveBeenCalledWith(2)
})
it('validates input against max constraint', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '15' } })
expect(onChange).not.toHaveBeenCalled()
})
it('validates input against min constraint', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={5} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '2' } })
expect(onChange).not.toHaveBeenCalled()
})
it('accepts input within min and max constraints', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={0} max={100} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '50' } })
expect(onChange).toHaveBeenCalledWith(50)
})
it('handles negative min and max values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={-10} max={10} value={0} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).toHaveBeenCalledWith(-1)
})
it('prevents decrement below negative min', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={-10} value={-10} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('applies wrapClassName to outer div', () => {
const onChange = vi.fn()
const wrapClassName = 'custom-wrap-class'
render(<InputNumber onChange={onChange} wrapClassName={wrapClassName} />)
const wrapper = screen.getByTestId('input-number-wrapper')
expect(wrapper).toHaveClass(wrapClassName)
})
it('applies wrapperClassName to outer div for Input compatibility', () => {
const onChange = vi.fn()
const wrapperClassName = 'custom-input-wrapper'
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
const input = screen.getByRole('textbox')
const wrapper = screen.getByTestId('input-number-wrapper')
expect(input).not.toHaveAttribute('wrapperClassName')
expect(wrapper).toHaveClass(wrapperClassName)
})
it('applies styleCss to the input element', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
})
it('applies controlWrapClassName to control buttons container', () => {
const onChange = vi.fn()
const controlWrapClassName = 'custom-control-wrap'
render(<InputNumber onChange={onChange} controlWrapClassName={controlWrapClassName} />)
const controlDiv = screen.getByTestId('input-number-controls')
expect(controlDiv).toHaveClass(controlWrapClassName)
})
it('applies controlClassName to individual control buttons', () => {
const onChange = vi.fn()
const controlClassName = 'custom-control'
render(<InputNumber onChange={onChange} controlClassName={controlClassName} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
expect(incrementBtn).toHaveClass(controlClassName)
expect(decrementBtn).toHaveClass(controlClassName)
})
it('applies regular-size classes for control buttons when size is regular', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} size="regular" />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
expect(incrementBtn).toHaveClass('pt-1')
expect(decrementBtn).toHaveClass('pb-1')
})
it('handles zero as a valid input', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('prevents exact max boundary increment', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={10} max={10} />)
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(onChange).not.toHaveBeenCalled()
})
it('prevents exact min boundary decrement', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={0} min={0} />)
await user.click(screen.getByRole('button', { name: /decrement/i }))
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -1,479 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import { InputNumber } from '.'
const meta = {
title: 'Base/Data Entry/InputNumber',
component: InputNumber,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current value',
},
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
min: {
control: 'number',
description: 'Minimum value',
},
max: {
control: 'number',
description: 'Maximum value',
},
amount: {
control: 'number',
description: 'Step amount for increment/decrement',
},
unit: {
control: 'text',
description: 'Unit text displayed (e.g., "px", "ms")',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
defaultValue: {
control: 'number',
description: 'Default value when undefined',
},
},
args: {
onChange: (value) => {
console.log('Value changed:', value)
},
},
} satisfies Meta<typeof InputNumber>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputNumberDemo = (args: any) => {
const [value, setValue] = useState(args.value ?? 0)
return (
<div style={{ width: '300px' }}>
<InputNumber
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue)
console.log('Value changed:', newValue)
}}
/>
<div className="mt-3 text-sm text-gray-600">
Current value:
{' '}
<span className="font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
size: 'regular',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'large',
},
}
// With min/max constraints
export const WithMinMax: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 5,
min: 0,
max: 10,
size: 'regular',
},
}
// With custom step amount
export const CustomStepAmount: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 50,
amount: 5,
min: 0,
max: 100,
size: 'regular',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 100,
unit: 'px',
min: 0,
max: 1000,
amount: 10,
size: 'regular',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 42,
disabled: true,
size: 'regular',
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 2.5,
amount: 0.5,
min: 0,
max: 10,
size: 'regular',
},
}
// Negative values allowed
export const NegativeValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
min: -100,
max: 100,
amount: 10,
size: 'regular',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState(10)
const [largeValue, setLargeValue] = useState(20)
return (
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<InputNumber
size="regular"
value={regularValue}
onChange={setRegularValue}
min={0}
max={100}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<InputNumber
size="large"
value={largeValue}
onChange={setLargeValue}
min={0}
max={100}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Font size picker
const FontSizePickerDemo = () => {
const [fontSize, setFontSize] = useState(16)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Font Size</label>
<InputNumber
value={fontSize}
onChange={setFontSize}
min={8}
max={72}
amount={2}
unit="px"
/>
</div>
<div className="rounded-lg bg-gray-50 p-4">
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
Preview Text
</p>
</div>
</div>
</div>
)
}
export const FontSizePicker: Story = {
render: () => <FontSizePickerDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Quantity selector
const QuantitySelectorDemo = () => {
const [quantity, setQuantity] = useState(1)
const pricePerItem = 29.99
const total = (quantity * pricePerItem).toFixed(2)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
<p className="text-sm text-gray-500">
$
{pricePerItem}
{' '}
each
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Quantity</label>
<InputNumber
value={quantity}
onChange={setQuantity}
min={1}
max={99}
amount={1}
/>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Total</span>
<span className="text-lg font-semibold text-gray-900">
$
{total}
</span>
</div>
</div>
</div>
</div>
)
}
export const QuantitySelector: Story = {
render: () => <QuantitySelectorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Timer settings
const TimerSettingsDemo = () => {
const [hours, setHours] = useState(0)
const [minutes, setMinutes] = useState(15)
const [seconds, setSeconds] = useState(30)
const totalSeconds = hours * 3600 + minutes * 60 + seconds
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Hours</label>
<InputNumber
value={hours}
onChange={setHours}
min={0}
max={23}
unit="h"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Minutes</label>
<InputNumber
value={minutes}
onChange={setMinutes}
min={0}
max={59}
unit="m"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Seconds</label>
<InputNumber
value={seconds}
onChange={setSeconds}
min={0}
max={59}
unit="s"
/>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-3">
<div className="text-sm text-gray-600">
Total duration:
{' '}
<span className="font-semibold">
{totalSeconds}
{' '}
seconds
</span>
</div>
</div>
</div>
</div>
)
}
export const TimerSettings: Story = {
render: () => <TimerSettingsDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Animation settings
const AnimationSettingsDemo = () => {
const [duration, setDuration] = useState(300)
const [delay, setDelay] = useState(0)
const [iterations, setIterations] = useState(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">Animation Properties</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Duration</label>
<InputNumber
value={duration}
onChange={setDuration}
min={0}
max={5000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Delay</label>
<InputNumber
value={delay}
onChange={setDelay}
min={0}
max={2000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Iterations</label>
<InputNumber
value={iterations}
onChange={setIterations}
min={1}
max={10}
amount={1}
/>
</div>
<div className="mt-2 rounded-lg bg-gray-50 p-4">
<div className="font-mono text-xs text-gray-700">
animation:
{' '}
{duration}
ms
{' '}
{delay}
ms
{' '}
{iterations}
</div>
</div>
</div>
</div>
)
}
export const AnimationSettings: Story = {
render: () => <AnimationSettingsDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Temperature control
const TemperatureControlDemo = () => {
const [temperature, setTemperature] = useState(20)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
<InputNumber
size="large"
value={temperature}
onChange={setTemperature}
min={16}
max={30}
amount={0.5}
unit="°C"
/>
</div>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
<div>
<div className="text-xs text-gray-500">Celsius</div>
<div className="text-2xl font-semibold text-gray-900">
{temperature}
°C
</div>
</div>
<div>
<div className="text-xs text-gray-500">Fahrenheit</div>
<div className="text-2xl font-semibold text-gray-900">
{fahrenheit}
°F
</div>
</div>
</div>
</div>
</div>
)
}
export const TemperatureControl: Story = {
render: () => <TemperatureControlDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'regular',
min: 0,
max: 100,
amount: 1,
unit: '',
disabled: false,
defaultValue: 0,
},
}

View File

@ -1,193 +0,0 @@
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
import { useCallback } from 'react'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '@/app/components/base/ui/number-field'
import { cn } from '@/utils/classnames'
type InputNumberInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
>
export type InputNumberProps = InputNumberInputProps & {
unit?: string
value?: number
onChange: (value: number) => void
amount?: number
size?: 'regular' | 'large'
max?: number
min?: number
step?: number | 'any'
defaultValue?: number
disabled?: boolean
wrapClassName?: string
wrapperClassName?: string
styleCss?: CSSProperties
controlWrapClassName?: string
controlClassName?: string
type?: 'number'
}
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
'increment-press',
'decrement-press',
])
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
if (typeof min === 'number' && value < min)
return false
if (typeof max === 'number' && value > max)
return false
return true
}
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
)
const exceedsStepBounds = ({
value,
reason,
stepAmount,
min,
max,
}: {
value?: number
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
stepAmount: number
min?: number
max?: number
}) => {
if (typeof value !== 'number')
return false
if (reason === 'increment-press' && typeof max === 'number')
return value + stepAmount > max
if (reason === 'decrement-press' && typeof min === 'number')
return value - stepAmount < min
return false
}
export const InputNumber: FC<InputNumberProps> = (props) => {
const {
unit,
className,
wrapperClassName,
styleCss,
onChange,
amount,
value,
size = 'regular',
max,
min,
defaultValue,
wrapClassName,
controlWrapClassName,
controlClassName,
disabled,
step,
id,
name,
readOnly,
required,
type: _type,
...rest
} = props
const resolvedStep = resolveStep(amount, step)
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
const handleValueChange = useCallback((
nextValue: number | null,
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
) => {
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
onChange(defaultValue ?? 0)
return
}
if (nextValue === null) {
onChange(0)
return
}
if (exceedsStepBounds({
value,
reason: eventDetails.reason,
stepAmount,
min,
max,
})) {
return
}
if (!isValueWithinBounds(nextValue, min, max))
return
onChange(nextValue)
}, [defaultValue, max, min, onChange, stepAmount, value])
return (
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
<NumberField
className="min-w-0 grow"
value={value ?? null}
min={min}
max={max}
step={resolvedStep}
disabled={disabled}
readOnly={readOnly}
required={required}
id={id}
name={name}
allowOutOfRange
onValueChange={handleValueChange}
>
<NumberFieldGroup size={size}>
<NumberFieldInput
{...rest}
size={size}
style={styleCss}
className={className}
/>
{unit && (
<NumberFieldUnit size={size}>
{unit}
</NumberFieldUnit>
)}
<NumberFieldControls
data-testid="input-number-controls"
className={controlWrapClassName}
>
<NumberFieldIncrement
aria-label="increment"
size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
</NumberFieldIncrement>
<NumberFieldDecrement
aria-label="decrement"
size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
</NumberFieldDecrement>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
)
}

View File

@ -112,6 +112,63 @@ describe('ParamItem', () => {
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8)
})
it('should reset the textbox and slider when users clear the input', async () => {
const user = userEvent.setup()
const StatefulParamItem = () => {
const [value, setValue] = useState(defaultProps.value)
return (
<ParamItem
{...defaultProps}
value={value}
onChange={(key: string, nextValue: number) => {
defaultProps.onChange(key, nextValue)
setValue(nextValue)
}}
/>
)
}
render(<StatefulParamItem />)
const input = screen.getByRole('textbox')
await user.clear(input)
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
await user.tab()
expect(input).toHaveValue('0')
})
it('should clamp out-of-range text edits before updating state', async () => {
const user = userEvent.setup()
const StatefulParamItem = () => {
const [value, setValue] = useState(defaultProps.value)
return (
<ParamItem
{...defaultProps}
value={value}
onChange={(key: string, nextValue: number) => {
defaultProps.onChange(key, nextValue)
setValue(nextValue)
}}
/>
)
}
render(<StatefulParamItem />)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '1.5')
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
expect(screen.getByRole('slider')).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')

View File

@ -3,7 +3,14 @@ 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 { InputNumber } from '../input-number'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '../ui/number-field'
type Props = {
className?: string
@ -36,7 +43,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
}}
/>
)}
<span className="system-sm-semibold mr-1 text-text-secondary">{name}</span>
<span className="mr-1 text-text-secondary system-sm-semibold">{name}</span>
{!noTooltip && (
<Tooltip
triggerClassName="w-4 h-4 shrink-0"
@ -47,20 +54,22 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
</div>
<div className="mt-1 flex items-center">
<div className="mr-3 flex shrink-0 items-center">
<InputNumber
<NumberField
disabled={!enable}
type="number"
min={min}
max={max}
step={step}
amount={step}
size="regular"
value={value}
onChange={(value) => {
onChange(id, value)
}}
className="w-[72px]"
/>
onValueChange={nextValue => onChange(id, nextValue ?? min)}
>
<NumberFieldGroup size="regular">
<NumberFieldInput size="regular" className="w-[72px]" />
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
<div className="flex grow items-center">
<Slider

View File

@ -1,3 +1,11 @@
import type { ReactNode } from 'react'
import type {
NumberFieldButtonProps,
NumberFieldControlsProps,
NumberFieldGroupProps,
NumberFieldInputProps,
NumberFieldUnitProps,
} from '../index'
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { render, screen } from '@testing-library/react'
import {
@ -10,104 +18,258 @@ import {
NumberFieldUnit,
} from '../index'
type RenderNumberFieldOptions = {
defaultValue?: number
groupProps?: Partial<NumberFieldGroupProps>
inputProps?: Partial<NumberFieldInputProps>
unitProps?: Partial<NumberFieldUnitProps> & { children?: ReactNode }
controlsProps?: Partial<NumberFieldControlsProps>
incrementProps?: Partial<NumberFieldButtonProps>
decrementProps?: Partial<NumberFieldButtonProps>
}
const renderNumberField = ({
defaultValue = 8,
groupProps,
inputProps,
unitProps,
controlsProps,
incrementProps,
decrementProps,
}: RenderNumberFieldOptions = {}) => {
const {
children: unitChildren = 'ms',
...restUnitProps
} = unitProps ?? {}
return render(
<NumberField defaultValue={defaultValue}>
<NumberFieldGroup data-testid="group" {...groupProps}>
<NumberFieldInput
aria-label="Amount"
data-testid="input"
{...inputProps}
/>
{unitProps && (
<NumberFieldUnit data-testid="unit" {...restUnitProps}>
{unitChildren}
</NumberFieldUnit>
)}
{(controlsProps || incrementProps || decrementProps) && (
<NumberFieldControls data-testid="controls" {...controlsProps}>
<NumberFieldIncrement data-testid="increment" {...incrementProps} />
<NumberFieldDecrement data-testid="decrement" {...decrementProps} />
</NumberFieldControls>
)}
</NumberFieldGroup>
</NumberField>,
)
}
describe('NumberField wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Export mapping should stay aligned with the Base UI primitive.
describe('Exports', () => {
it('should map NumberField to the matching base primitive root', () => {
expect(NumberField).toBe(BaseNumberField.Root)
})
})
describe('Variants', () => {
it('should apply regular variant classes and forward className to group and input', () => {
render(
<NumberField defaultValue={12}>
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
<NumberFieldInput
aria-label="Regular amount"
placeholder="Regular placeholder"
size="regular"
className="custom-input"
/>
</NumberFieldGroup>
</NumberField>,
)
// Group and input wrappers should preserve the design-system variants and DOM defaults.
describe('Group and input', () => {
it('should apply regular group classes by default and merge custom className', () => {
renderNumberField({
groupProps: {
className: 'custom-group',
},
})
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Regular amount' })
expect(group).toHaveClass('radius-md')
expect(group).toHaveClass('custom-group')
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
expect(input).toHaveClass('px-3')
expect(input).toHaveClass('py-[7px]')
expect(input).toHaveClass('custom-input')
})
it('should apply large variant classes to grouped parts when large size is provided', () => {
render(
<NumberField defaultValue={24}>
<NumberFieldGroup size="large" data-testid="group">
<NumberFieldInput aria-label="Large amount" size="large" />
<NumberFieldUnit size="large">ms</NumberFieldUnit>
<NumberFieldControls>
<NumberFieldIncrement aria-label="Increment amount" size="large" />
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
it('should apply large group and input classes when large size is provided', () => {
renderNumberField({
groupProps: {
size: 'large',
},
inputProps: {
size: 'large',
},
})
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Large amount' })
const unit = screen.getByText('ms')
const increment = screen.getByRole('button', { name: 'Increment amount' })
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
const input = screen.getByTestId('input')
expect(group).toHaveClass('radius-lg')
expect(input).toHaveClass('px-4')
expect(input).toHaveClass('py-2')
expect(unit).toHaveClass('flex')
expect(unit).toHaveClass('items-center')
expect(unit).toHaveClass('pr-2.5')
expect(increment).toHaveClass('pt-1.5')
expect(decrement).toHaveClass('pb-1.5')
})
it('should set input defaults and forward passthrough props', () => {
renderNumberField({
inputProps: {
className: 'custom-input',
placeholder: 'Regular placeholder',
required: true,
},
})
const input = screen.getByRole('textbox', { name: 'Amount' })
expect(input).toHaveAttribute('autoComplete', 'off')
expect(input).toHaveAttribute('autoCorrect', 'off')
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
expect(input).toBeRequired()
expect(input).toHaveClass('px-3')
expect(input).toHaveClass('py-[7px]')
expect(input).toHaveClass('system-sm-regular')
expect(input).toHaveClass('custom-input')
})
})
describe('Passthrough props', () => {
it('should forward passthrough props and custom classes to controls and buttons', () => {
render(
<NumberField defaultValue={8}>
<NumberFieldGroup size="regular">
<NumberFieldInput aria-label="Amount" size="regular" />
<NumberFieldControls className="custom-controls" data-testid="controls">
<NumberFieldIncrement
aria-label="Increment"
size="regular"
className="custom-increment"
data-track-id="increment-track"
/>
<NumberFieldDecrement
aria-label="Decrement"
size="regular"
className="custom-decrement"
data-track-id="decrement-track"
/>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
// Unit and controls wrappers should preserve layout tokens and HTML passthrough props.
describe('Unit and controls', () => {
it.each([
['regular', 'pr-2'],
['large', 'pr-2.5'],
] as const)('should apply the %s unit spacing variant', (size, spacingClass) => {
renderNumberField({
unitProps: {
size,
className: 'custom-unit',
title: `unit-${size}`,
},
})
const unit = screen.getByTestId('unit')
expect(unit).toHaveTextContent('ms')
expect(unit).toHaveAttribute('title', `unit-${size}`)
expect(unit).toHaveClass('custom-unit')
expect(unit).toHaveClass(spacingClass)
})
it('should forward passthrough props to controls', () => {
renderNumberField({
controlsProps: {
className: 'custom-controls',
title: 'controls-title',
},
})
const controls = screen.getByTestId('controls')
const increment = screen.getByRole('button', { name: 'Increment' })
const decrement = screen.getByRole('button', { name: 'Decrement' })
expect(controls).toHaveClass('border-l')
expect(controls).toHaveAttribute('title', 'controls-title')
expect(controls).toHaveClass('custom-controls')
})
})
// Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants.
describe('Control buttons', () => {
it('should provide localized aria labels and default icons when labels are not provided', () => {
renderNumberField({
controlsProps: {},
})
const increment = screen.getByRole('button', { name: 'common.operation.increment' })
const decrement = screen.getByRole('button', { name: 'common.operation.decrement' })
expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument()
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should preserve explicit aria labels and custom children', () => {
renderNumberField({
controlsProps: {},
incrementProps: {
'aria-label': 'Increase amount',
'children': <span data-testid="custom-increment-icon">+</span>,
},
decrementProps: {
'aria-label': 'Decrease amount',
'children': <span data-testid="custom-decrement-icon">-</span>,
},
})
const increment = screen.getByRole('button', { name: 'Increase amount' })
const decrement = screen.getByRole('button', { name: 'Decrease amount' })
expect(increment).toContainElement(screen.getByTestId('custom-increment-icon'))
expect(decrement).toContainElement(screen.getByTestId('custom-decrement-icon'))
expect(increment.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
it('should keep the fallback aria labels when aria-label is omitted in props', () => {
renderNumberField({
controlsProps: {},
incrementProps: {
'aria-label': undefined,
},
decrementProps: {
'aria-label': undefined,
},
})
expect(screen.getByRole('button', { name: 'common.operation.increment' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.decrement' })).toBeInTheDocument()
})
it('should rely on aria-labelledby when provided instead of injecting a translated aria-label', () => {
render(
<>
<span id="increment-label">Increment from label</span>
<span id="decrement-label">Decrement from label</span>
<NumberField defaultValue={8}>
<NumberFieldGroup size="regular">
<NumberFieldInput aria-label="Amount" size="regular" />
<NumberFieldControls>
<NumberFieldIncrement aria-labelledby="increment-label" size="regular" />
<NumberFieldDecrement aria-labelledby="decrement-label" size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</>,
)
const increment = screen.getByRole('button', { name: 'Increment from label' })
const decrement = screen.getByRole('button', { name: 'Decrement from label' })
expect(increment).not.toHaveAttribute('aria-label')
expect(decrement).not.toHaveAttribute('aria-label')
})
it.each([
['regular', 'pt-1', 'pb-1'],
['large', 'pt-1.5', 'pb-1.5'],
] as const)('should apply the %s control button compound spacing classes', (size, incrementClass, decrementClass) => {
renderNumberField({
controlsProps: {},
incrementProps: {
size,
className: 'custom-increment',
},
decrementProps: {
size,
className: 'custom-decrement',
title: `decrement-${size}`,
},
})
const increment = screen.getByTestId('increment')
const decrement = screen.getByTestId('decrement')
expect(increment).toHaveClass(incrementClass)
expect(increment).toHaveClass('custom-increment')
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
expect(decrement).toHaveClass(decrementClass)
expect(decrement).toHaveClass('custom-decrement')
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
expect(decrement).toHaveAttribute('title', `decrement-${size}`)
})
})
})

View File

@ -0,0 +1,285 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useId, useState } from 'react'
import { cn } from '@/utils/classnames'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '.'
type DemoFieldProps = {
label: string
helperText: string
placeholder: string
size: 'regular' | 'large'
unit?: string
defaultValue?: number | null
min?: number
max?: number
step?: number
disabled?: boolean
readOnly?: boolean
showCurrentValue?: boolean
widthClassName?: string
formatValue?: (value: number | null) => string
}
const formatNumericValue = (value: number | null, unit?: string) => {
if (value === null)
return 'Empty'
if (!unit)
return String(value)
return `${value} ${unit}`
}
const FieldLabel = ({
inputId,
label,
helperText,
}: Pick<DemoFieldProps, 'label' | 'helperText'> & { inputId: string }) => (
<div className="space-y-1">
<label htmlFor={inputId} className="text-text-secondary system-sm-medium">
{label}
</label>
<p className="text-text-tertiary system-xs-regular">{helperText}</p>
</div>
)
const DemoField = ({
label,
helperText,
placeholder,
size,
unit,
defaultValue,
min,
max,
step,
disabled,
readOnly,
showCurrentValue,
widthClassName,
formatValue,
}: DemoFieldProps) => {
const inputId = useId()
const [value, setValue] = useState<number | null>(defaultValue ?? null)
return (
<div className={cn('flex w-full max-w-80 flex-col gap-2', widthClassName)}>
<FieldLabel inputId={inputId} label={label} helperText={helperText} />
<NumberField
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
readOnly={readOnly}
onValueChange={setValue}
>
<NumberFieldGroup size={size}>
<NumberFieldInput
id={inputId}
aria-label={label}
placeholder={placeholder}
size={size}
/>
{unit && <NumberFieldUnit size={size}>{unit}</NumberFieldUnit>}
<NumberFieldControls>
<NumberFieldIncrement size={size} />
<NumberFieldDecrement size={size} />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
{showCurrentValue && (
<p className="text-text-quaternary system-xs-regular">
Current value:
{' '}
{formatValue ? formatValue(value) : formatNumericValue(value, unit)}
</p>
)}
</div>
)
}
const meta = {
title: 'Base/Form/NumberField',
component: NumberField,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof NumberField>
export default meta
type Story = StoryObj<typeof meta>
export const VariantMatrix: Story = {
render: () => (
<div className="grid w-[720px] gap-6 md:grid-cols-2">
<DemoField
label="Top K"
helperText="Regular size without suffix. Covers the regular group, input, and control button spacing."
placeholder="Set top K"
size="regular"
defaultValue={3}
min={1}
max={10}
step={1}
/>
<DemoField
label="Score threshold"
helperText="Regular size with a suffix so the regular unit variant is visible."
placeholder="Set threshold"
size="regular"
unit="%"
defaultValue={85}
min={0}
max={100}
step={1}
/>
<DemoField
label="Chunk overlap"
helperText="Large size without suffix. Matches the larger dataset form treatment."
placeholder="Set overlap"
size="large"
defaultValue={64}
min={0}
max={512}
step={16}
/>
<DemoField
label="Max segment length"
helperText="Large size with suffix so the large unit variant is also enumerated."
placeholder="Set length"
size="large"
unit="tokens"
defaultValue={512}
min={1}
max={4000}
step={32}
/>
</div>
),
}
export const DecimalInputs: Story = {
render: () => (
<div className="grid w-[720px] gap-6 md:grid-cols-2">
<DemoField
label="Score threshold"
helperText="Two-decimal precision with a 0.01 step, like retrieval tuning fields."
placeholder="0.00"
size="regular"
defaultValue={0.82}
min={0}
max={1}
step={0.01}
showCurrentValue
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
/>
<DemoField
label="Temperature"
helperText="One-decimal stepping for generation parameters."
placeholder="0.0"
size="large"
defaultValue={0.7}
min={0}
max={2}
step={0.1}
showCurrentValue
formatValue={value => value === null ? 'Empty' : value.toFixed(1)}
/>
<DemoField
label="Penalty"
helperText="Starts empty so the placeholder and empty numeric state are both visible."
placeholder="Optional"
size="regular"
defaultValue={null}
min={0}
max={2}
step={0.05}
showCurrentValue
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
/>
<DemoField
label="Latency budget"
helperText="Decimal input with a unit suffix and larger spacing."
placeholder="0.0"
size="large"
unit="s"
defaultValue={1.5}
min={0.5}
max={10}
step={0.5}
showCurrentValue
formatValue={value => value === null ? 'Empty' : `${value.toFixed(1)} s`}
/>
</div>
),
}
export const BoundariesAndStates: Story = {
render: () => (
<div className="grid w-[720px] gap-6 md:grid-cols-2">
<DemoField
label="HTTP status code"
helperText="Integer-only style usage with tighter bounds from 100 to 599."
placeholder="200"
size="regular"
defaultValue={200}
min={100}
max={599}
step={1}
showCurrentValue
/>
<DemoField
label="Request timeout"
helperText="Bounded regular input with suffix, common in system settings."
placeholder="Set timeout"
size="regular"
unit="ms"
defaultValue={1200}
min={100}
max={10000}
step={100}
showCurrentValue
/>
<DemoField
label="Retry count"
helperText="Disabled state preserves the layout while switching to disabled tokens."
placeholder="Retry count"
size="large"
defaultValue={5}
min={0}
max={10}
step={1}
disabled
showCurrentValue
/>
<DemoField
label="Archived score threshold"
helperText="Read-only state keeps the same structure but removes interactive affordances."
placeholder="0.00"
size="large"
unit="%"
defaultValue={92}
min={0}
max={100}
step={1}
readOnly
showCurrentValue
/>
</div>
),
}

View File

@ -4,9 +4,11 @@ import type { VariantProps } from 'class-variance-authority'
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
export const NumberField = BaseNumberField.Root
export type NumberFieldRootProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Root>
export const numberFieldGroupVariants = cva(
[
@ -15,7 +17,7 @@ export const numberFieldGroupVariants = cva(
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
'data-[readonly]:shadow-none motion-reduce:transition-none',
'data-[readonly]:shadow-none data-[readonly]:hover:border-transparent data-[readonly]:hover:bg-components-input-bg-normal motion-reduce:transition-none',
],
{
variants: {
@ -29,8 +31,9 @@ export const numberFieldGroupVariants = cva(
},
},
)
export type NumberFieldSize = NonNullable<VariantProps<typeof numberFieldGroupVariants>['size']>
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
export type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
export function NumberFieldGroup({
className,
@ -65,7 +68,7 @@ export const numberFieldInputVariants = cva(
},
)
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
export type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
export function NumberFieldInput({
className,
@ -95,7 +98,7 @@ export const numberFieldUnitVariants = cva(
},
)
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
export type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
export function NumberFieldUnit({
className,
@ -114,7 +117,7 @@ export const numberFieldControlsVariants = cva(
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
)
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
export type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
export function NumberFieldControls({
className,
@ -130,11 +133,12 @@ export function NumberFieldControls({
export const numberFieldControlButtonVariants = cva(
[
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
'flex touch-manipulation select-none items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent group-data-[disabled]/number-field:focus-visible:bg-transparent group-data-[disabled]/number-field:focus-visible:ring-0',
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent group-data-[readonly]/number-field:focus-visible:bg-transparent group-data-[readonly]/number-field:focus-visible:ring-0',
'motion-reduce:transition-none',
],
{
@ -182,30 +186,42 @@ type NumberFieldButtonVariantProps = Omit<
'direction'
>
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
export type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
export function NumberFieldIncrement({
className,
children,
size = 'regular',
...props
}: NumberFieldButtonProps) {
const { t } = useTranslation()
return (
<BaseNumberField.Increment
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
{...props}
/>
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.increment', { ns: 'common' }))}
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
>
{children ?? <span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />}
</BaseNumberField.Increment>
)
}
export function NumberFieldDecrement({
className,
children,
size = 'regular',
...props
}: NumberFieldButtonProps) {
const { t } = useTranslation()
return (
<BaseNumberField.Decrement
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
{...props}
/>
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.decrement', { ns: 'common' }))}
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
>
{children ?? <span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />}
</BaseNumberField.Decrement>
)
}

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs'
@ -61,6 +61,21 @@ describe('MaxLengthInput', () => {
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should reset to the minimum when users clear the value', () => {
const onChange = vi.fn()
render(<MaxLengthInput value={500} onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(1)
})
it('should clamp out-of-range text edits before updating state', () => {
const onChange = vi.fn()
render(<MaxLengthInput value={500} max={1000} onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '1200' } })
expect(onChange).toHaveBeenLastCalledWith(1000)
})
})
describe('OverlapInput', () => {
@ -89,4 +104,19 @@ describe('OverlapInput', () => {
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should reset to the minimum when users clear the value', () => {
const onChange = vi.fn()
render(<OverlapInput value={50} onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(1)
})
it('should clamp out-of-range text edits before updating state', () => {
const onChange = vi.fn()
render(<OverlapInput value={50} max={100} onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '150' } })
expect(onChange).toHaveBeenLastCalledWith(100)
})
})

View File

@ -1,10 +1,18 @@
import type { FC, PropsWithChildren, ReactNode } from 'react'
import type { InputProps } from '@/app/components/base/input'
import type { InputNumberProps } from '@/app/components/base/input-number'
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '@/app/components/base/ui/number-field'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { InputNumber } from '@/app/components/base/input-number'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '@/app/components/base/ui/number-field'
import { env } from '@/env'
const TextLabel: FC<PropsWithChildren> = (props) => {
@ -25,7 +33,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
return (
<FormField label={(
<div className="mb-1 flex items-center">
<span className="system-sm-semibold mr-0.5">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
<span className="mr-0.5 system-sm-semibold">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
<Tooltip
popupContent={(
<div className="max-w-[200px]">
@ -46,19 +54,69 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
)
}
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
type CompoundNumberInputProps = Omit<NumberFieldRootProps, 'children' | 'className' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & {
unit?: ReactNode
size?: NumberFieldSize
onChange: (value: number) => void
}
function CompoundNumberInput({
onChange,
unit,
size = 'large',
className,
...props
}: CompoundNumberInputProps) {
const { value, defaultValue, min, max, step, disabled, readOnly, required, id, name, onBlur, ...inputProps } = props
const emptyValue = defaultValue ?? min ?? 0
return (
<NumberField
value={value}
defaultValue={defaultValue}
min={min}
max={max}
step={step}
disabled={disabled}
readOnly={readOnly}
required={required}
id={id}
name={name}
onValueChange={value => onChange(value ?? emptyValue)}
>
<NumberFieldGroup size={size}>
<NumberFieldInput
{...inputProps}
size={size}
className={className}
onBlur={onBlur}
/>
{Boolean(unit) && (
<NumberFieldUnit size={size}>
{unit}
</NumberFieldUnit>
)}
<NumberFieldControls>
<NumberFieldIncrement size={size} />
<NumberFieldDecrement size={size} />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
)
}
export const MaxLengthInput: FC<CompoundNumberInputProps> = (props) => {
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
const { t } = useTranslation()
return (
<FormField label={(
<div className="system-sm-semibold mb-1">
<div className="mb-1 system-sm-semibold">
{t('stepTwo.maxLength', { ns: 'datasetCreation' })}
</div>
)}
>
<InputNumber
type="number"
<CompoundNumberInput
size="large"
placeholder={`${maxValue}`}
max={maxValue}
@ -69,7 +127,7 @@ export const MaxLengthInput: FC<InputNumberProps> = (props) => {
)
}
export const OverlapInput: FC<InputNumberProps> = (props) => {
export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
const { t } = useTranslation()
return (
<FormField label={(
@ -85,8 +143,7 @@ export const OverlapInput: FC<InputNumberProps> = (props) => {
</div>
)}
>
<InputNumber
type="number"
<CompoundNumberInput
size="large"
placeholder={t('stepTwo.overlap', { ns: 'datasetCreation' }) || ''}
min={1}

View File

@ -103,6 +103,18 @@ describe('InputCombined', () => {
expect(handleChange).toHaveBeenCalled()
})
it('should reset cleared number input to 0', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(handleChange).toHaveBeenCalledWith(0)
})
it('should display current value for number type', () => {
const handleChange = vi.fn()
render(

View File

@ -2,7 +2,14 @@
import type { FC } from 'react'
import * as React from 'react'
import Input from '@/app/components/base/input'
import { InputNumber } from '@/app/components/base/input-number'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { cn } from '@/utils/classnames'
import Datepicker from '../base/date-picker'
import { DataType } from '../types'
@ -36,15 +43,23 @@ const InputCombined: FC<Props> = ({
if (type === DataType.number) {
return (
<div className="grow text-[0]">
<InputNumber
className={cn(className, 'rounded-l-md')}
<NumberField
className="min-w-0"
value={value}
onChange={onChange}
size="regular"
controlWrapClassName="overflow-hidden"
controlClassName="pt-0 pb-0"
readOnly={readOnly}
/>
onValueChange={value => onChange(value ?? 0)}
>
<NumberFieldGroup size="regular">
<NumberFieldInput
size="regular"
className={cn(className, 'rounded-l-md')}
/>
<NumberFieldControls className="overflow-hidden">
<NumberFieldIncrement size="regular" className="pb-0 pt-0" />
<NumberFieldDecrement size="regular" className="pb-0 pt-0" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
)
}

View File

@ -190,7 +190,7 @@ describe('IndexMethod', () => {
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should handle keywordNumber of 0', () => {
it('should handle minimum keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('0')

View File

@ -24,9 +24,8 @@ describe('KeyWordNumber', () => {
it('should render tooltip with question icon', () => {
render(<KeyWordNumber {...defaultProps} />)
// RiQuestionLine renders as an svg
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
const questionIcon = container?.querySelector('svg')
const questionIcon = container?.querySelector('.i-ri-question-line')
expect(questionIcon).toBeInTheDocument()
})
@ -88,15 +87,22 @@ describe('KeyWordNumber', () => {
expect(handleChange).toHaveBeenCalled()
})
it('should not call onKeywordNumberChange with undefined value', () => {
it('should reset to 0 when users clear the input', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
expect(handleChange).not.toHaveBeenCalled()
expect(handleChange).toHaveBeenCalledWith(0)
})
it('should clamp out-of-range edits before updating state', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '60' } })
expect(handleChange).toHaveBeenLastCalledWith(50)
})
})

View File

@ -1,10 +1,19 @@
import { RiQuestionLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InputNumber } from '@/app/components/base/input-number'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
const MIN_KEYWORD_NUMBER = 0
const MAX_KEYWORD_NUMBER = 50
type KeyWordNumberProps = {
keywordNumber: number
@ -17,35 +26,44 @@ const KeyWordNumber = ({
}: KeyWordNumberProps) => {
const { t } = useTranslation()
const handleInputChange = useCallback((value: number | undefined) => {
if (value)
onKeywordNumberChange(value)
const handleInputChange = useCallback((value: number | null) => {
onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER)
}, [onKeywordNumberChange])
return (
<div className="flex items-center gap-x-1">
<div className="flex grow items-center gap-x-0.5">
<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
popupContent="number of keywords"
popupContent={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
>
<RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" />
<span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
</Tooltip>
</div>
<Slider
className="mr-3 w-[206px] shrink-0"
value={keywordNumber}
max={50}
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
onChange={onKeywordNumberChange}
/>
<InputNumber
wrapperClassName="shrink-0 w-12"
type="number"
<NumberField
className="w-12 shrink-0"
min={MIN_KEYWORD_NUMBER}
max={MAX_KEYWORD_NUMBER}
value={keywordNumber}
onChange={handleInputChange}
/>
onValueChange={handleInputChange}
>
<NumberFieldGroup size="regular">
<NumberFieldInput size="regular" />
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
)
}

View File

@ -0,0 +1,186 @@
import type { ReactNode } from 'react'
import type {
CredentialFormSchema,
CredentialFormSchemaNumberInput,
CredentialFormSchemaTextInput,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AgentStrategy } from '../agent-strategy'
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: null }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => () => '/docs',
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: unknown) => {
if (typeof value === 'string')
return value
if (value && typeof value === 'object' && 'en_US' in value)
return value.en_US
return 'label'
},
}))
vi.mock('../../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('../agent-strategy-selector', () => ({
AgentStrategySelector: () => <div data-testid="agent-strategy-selector" />,
}))
vi.mock('../field', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('../prompt/editor', () => ({
default: ({ value }: { value: string }) => <div data-testid="agent-strategy-editor">{value}</div>,
}))
type MockFormRenderProps = {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
nodeId?: string
nodeOutputVars?: unknown[]
availableNodes?: unknown[]
}
type MockFormProps = {
formSchemas: Array<{ variable: string }>
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
override?: [unknown, (schema: unknown, props: MockFormRenderProps) => ReactNode]
nodeId?: string
nodeOutputVars?: unknown[]
availableNodes?: unknown[]
}
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ formSchemas, value, onChange, override, nodeId, nodeOutputVars, availableNodes }: MockFormProps) => {
const renderOverride = override?.[1]
return (
<div data-testid="mock-form">
{formSchemas.map(schema => (
<div key={schema.variable}>
{renderOverride?.(schema, {
value,
onChange,
nodeId,
nodeOutputVars,
availableNodes,
})}
</div>
))}
</div>
)
},
}))
describe('AgentStrategy', () => {
const defaultProps = {
strategy: {
agent_strategy_provider_name: 'provider',
agent_strategy_name: 'strategy',
agent_strategy_label: 'Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'plugin',
},
onStrategyChange: vi.fn(),
formValue: {},
onFormValueChange: vi.fn(),
nodeOutputVars: [],
availableNodes: [],
nodeId: 'node-1',
}
beforeEach(() => {
vi.clearAllMocks()
})
const createTextNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput> = {}): CredentialFormSchema => ({
name: 'count',
variable: 'count',
label: createI18nLabel('Count'),
type: FormTypeEnum.textNumber,
required: false,
show_on: [],
default: '1',
...overrides,
} as unknown as CredentialFormSchema)
const createTextInputSchema = (overrides: Partial<CredentialFormSchemaTextInput> = {}): CredentialFormSchema => ({
name: 'prompt',
variable: 'prompt',
label: createI18nLabel('Prompt'),
type: FormTypeEnum.textInput,
required: false,
show_on: [],
default: 'hello',
...overrides,
})
it('should render text-number schemas when min and max are zero', () => {
render(
<AgentStrategy
{...defaultProps}
formSchema={[createTextNumberSchema({
min: 0,
max: 0,
default: '0',
})]}
/>,
)
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should skip text-number schemas when min is missing', () => {
render(
<AgentStrategy
{...defaultProps}
formSchema={[createTextNumberSchema({
max: 5,
})]}
/>,
)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should skip text-number schemas when max is missing', () => {
render(
<AgentStrategy
{...defaultProps}
formSchema={[createTextNumberSchema({
min: 0,
})]}
/>,
)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should render text-input schemas through the editor override', () => {
render(
<AgentStrategy
{...defaultProps}
formSchema={[createTextInputSchema()]}
/>,
)
expect(screen.getByTestId('agent-strategy-editor')).toHaveTextContent('hello')
})
})

View File

@ -9,9 +9,16 @@ import Link from 'next/link'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import { InputNumber } from '@/app/components/base/input-number'
import ListEmpty from '@/app/components/base/list-empty'
import Slider from '@/app/components/base/slider'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
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'
@ -116,11 +123,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
}
case FormTypeEnum.textNumber: {
const def = schema as CredentialFormSchemaNumberInput
if (!def.max || !def.min)
if (def.max == null || def.min == null)
return false
const defaultValue = schema.default ? Number.parseInt(schema.default) : 1
const value = props.value[schema.variable] || defaultValue
const value = props.value[schema.variable] ?? defaultValue
const onChange = (value: number) => {
props.onChange({ ...props.value, [schema.variable]: value })
}
@ -145,16 +152,20 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
min={def.min}
max={def.max}
/>
<InputNumber
<NumberField
value={value}
// TODO: maybe empty, handle this
onChange={onChange as any}
defaultValue={defaultValue}
size="regular"
min={def.min}
max={def.max}
className="w-12"
/>
onValueChange={nextValue => onChange(nextValue ?? defaultValue)}
>
<NumberFieldGroup size="regular">
<NumberFieldInput size="regular" className="w-12" />
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
</Field>
)

View File

@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TopKAndScoreThreshold from '../top-k-and-score-threshold'
describe('TopKAndScoreThreshold', () => {
const defaultProps = {
topK: 3,
onTopKChange: vi.fn(),
scoreThreshold: 0.4,
onScoreThresholdChange: vi.fn(),
isScoreThresholdEnabled: true,
onScoreThresholdEnabledChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should round top-k input values before notifying the parent', () => {
render(<TopKAndScoreThreshold {...defaultProps} />)
const [topKInput] = screen.getAllByRole('textbox')
fireEvent.change(topKInput, { target: { value: '3.7' } })
expect(defaultProps.onTopKChange).toHaveBeenCalledWith(4)
})
it('should round score-threshold input values to two decimals', () => {
render(<TopKAndScoreThreshold {...defaultProps} />)
const [, scoreThresholdInput] = screen.getAllByRole('textbox')
fireEvent.change(scoreThresholdInput, { target: { value: '0.456' } })
expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46)
})
})

View File

@ -1,8 +1,15 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InputNumber } from '@/app/components/base/input-number'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import { env } from '@/env'
export type TopKAndScoreThresholdProps = {
@ -40,17 +47,11 @@ const TopKAndScoreThreshold = ({
}: TopKAndScoreThresholdProps) => {
const { t } = useTranslation()
const handleTopKChange = useCallback((value: number) => {
let notOutRangeValue = Number.parseInt(value.toFixed(0))
notOutRangeValue = Math.max(TOP_K_VALUE_LIMIT.min, notOutRangeValue)
notOutRangeValue = Math.min(TOP_K_VALUE_LIMIT.max, notOutRangeValue)
onTopKChange?.(notOutRangeValue)
onTopKChange?.(Number.parseInt(value.toFixed(0)))
}, [onTopKChange])
const handleScoreThresholdChange = (value: number) => {
let notOutRangeValue = Number.parseFloat(value.toFixed(2))
notOutRangeValue = Math.max(SCORE_THRESHOLD_VALUE_LIMIT.min, notOutRangeValue)
notOutRangeValue = Math.min(SCORE_THRESHOLD_VALUE_LIMIT.max, notOutRangeValue)
onScoreThresholdChange?.(notOutRangeValue)
onScoreThresholdChange?.(Number.parseFloat(value.toFixed(2)))
}
return (
@ -63,14 +64,22 @@ const TopKAndScoreThreshold = ({
popupContent={t('datasetConfig.top_kTip', { ns: 'appDebug' })}
/>
</div>
<InputNumber
<NumberField
disabled={readonly}
type="number"
{...TOP_K_VALUE_LIMIT}
size="regular"
step={TOP_K_VALUE_LIMIT.amount}
min={TOP_K_VALUE_LIMIT.min}
max={TOP_K_VALUE_LIMIT.max}
value={topK}
onChange={handleTopKChange}
/>
onValueChange={value => handleTopKChange(value ?? 0)}
>
<NumberFieldGroup size="regular">
<NumberFieldInput size="regular" />
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
{
!hiddenScoreThreshold && (
@ -90,14 +99,22 @@ const TopKAndScoreThreshold = ({
popupContent={t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })}
/>
</div>
<InputNumber
<NumberField
disabled={readonly || !isScoreThresholdEnabled}
type="number"
{...SCORE_THRESHOLD_VALUE_LIMIT}
size="regular"
value={scoreThreshold}
onChange={handleScoreThresholdChange}
/>
step={SCORE_THRESHOLD_VALUE_LIMIT.step}
min={SCORE_THRESHOLD_VALUE_LIMIT.min}
max={SCORE_THRESHOLD_VALUE_LIMIT.max}
value={scoreThreshold ?? null}
onValueChange={value => handleScoreThresholdChange(value ?? 0)}
>
<NumberFieldGroup size="regular">
<NumberFieldInput size="regular" />
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
)
}

View File

@ -0,0 +1,133 @@
import type { WebhookTriggerNodeType } from '../types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import Panel from '../panel'
const {
mockHandleStatusCodeChange,
mockGenerateWebhookUrl,
} = vi.hoisted(() => ({
mockHandleStatusCodeChange: vi.fn(),
mockGenerateWebhookUrl: vi.fn(),
}))
vi.mock('../use-config', () => ({
DEFAULT_STATUS_CODE: 200,
MAX_STATUS_CODE: 399,
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
useConfig: () => ({
readOnly: false,
inputs: {
method: 'POST',
webhook_url: 'https://example.com/webhook',
webhook_debug_url: '',
content_type: 'application/json',
headers: [],
params: [],
body: [],
status_code: 200,
response_body: '',
},
handleMethodChange: vi.fn(),
handleContentTypeChange: vi.fn(),
handleHeadersChange: vi.fn(),
handleParamsChange: vi.fn(),
handleBodyChange: vi.fn(),
handleStatusCodeChange: mockHandleStatusCodeChange,
handleResponseBodyChange: vi.fn(),
generateWebhookUrl: mockGenerateWebhookUrl,
}),
}))
vi.mock('@/app/components/base/input-with-copy', () => ({
default: () => <div data-testid="input-with-copy" />,
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: () => <div data-testid="simple-select" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => (
<section>
<div>{title}</div>
{children}
</section>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: () => <div data-testid="output-vars" />,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div data-testid="split" />,
}))
vi.mock('../components/header-table', () => ({
default: () => <div data-testid="header-table" />,
}))
vi.mock('../components/parameter-table', () => ({
default: () => <div data-testid="parameter-table" />,
}))
vi.mock('../components/paragraph-input', () => ({
default: () => <div data-testid="paragraph-input" />,
}))
vi.mock('../utils/render-output-vars', () => ({
OutputVariablesContent: () => <div data-testid="output-variables-content" />,
}))
describe('WebhookTriggerPanel', () => {
const panelProps: NodePanelProps<WebhookTriggerNodeType> = {
id: 'node-1',
data: {
title: 'Webhook',
desc: 'Webhook',
type: BlockEnum.TriggerWebhook,
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: false,
status_code: 200,
response_body: '',
variables: [],
},
panelProps: {} as PanelProps,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should update the status code when users enter a parseable value', () => {
render(<Panel {...panelProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } })
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
})
it('should ignore clear changes until the value is committed', () => {
render(<Panel {...panelProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
fireEvent.blur(input)
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
})
})

View File

@ -6,11 +6,18 @@ import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InputNumber } from '@/app/components/base/input-number'
import InputWithCopy from '@/app/components/base/input-with-copy'
import { SimpleSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/app/components/base/ui/number-field'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
@ -18,7 +25,7 @@ import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import HeaderTable from './components/header-table'
import ParagraphInput from './components/paragraph-input'
import ParameterTable from './components/parameter-table'
import useConfig from './use-config'
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from './use-config'
import { OutputVariablesContent } from './utils/render-output-vars'
const i18nPrefix = 'nodes.triggerWebhook'
@ -56,7 +63,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
handleParamsChange,
handleBodyChange,
handleStatusCodeChange,
handleStatusCodeBlur,
handleResponseBodyChange,
generateWebhookUrl,
} = useConfig(id, data)
@ -134,7 +140,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
</div>
</Tooltip>
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
<div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning">
<div className="mt-1 px-0 py-[2px] text-text-warning system-xs-regular">
{t(`${i18nPrefix}.debugUrlPrivateAddressWarning`, { ns: 'workflow' })}
</div>
)}
@ -192,25 +198,35 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<Field title={t(`${i18nPrefix}.responseConfiguration`, { ns: 'workflow' })}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="system-sm-medium text-text-tertiary">
<label className="text-text-tertiary system-sm-medium">
{t(`${i18nPrefix}.statusCode`, { ns: 'workflow' })}
</label>
<InputNumber
value={inputs.status_code}
onChange={(value) => {
handleStatusCodeChange(value || 200)
}}
<NumberField
className="w-[120px]"
min={DEFAULT_STATUS_CODE}
max={MAX_STATUS_CODE}
value={inputs.status_code ?? DEFAULT_STATUS_CODE}
disabled={readOnly}
wrapClassName="w-[120px]"
className="h-8"
defaultValue={200}
onBlur={() => {
handleStatusCodeBlur(inputs.status_code)
onValueChange={value => value !== null && handleStatusCodeChange(value)}
onValueCommitted={(value, eventDetails) => {
if (eventDetails.reason === 'input-blur' || eventDetails.reason === 'input-clear')
handleStatusCodeChange(normalizeStatusCode(value ?? DEFAULT_STATUS_CODE))
}}
/>
>
<NumberFieldGroup size="regular">
<NumberFieldInput
size="regular"
className="h-8"
/>
<NumberFieldControls>
<NumberFieldIncrement size="regular" />
<NumberFieldDecrement size="regular" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
<div>
<label className="system-sm-medium mb-2 block text-text-tertiary">
<label className="mb-2 block text-text-tertiary system-sm-medium">
{t(`${i18nPrefix}.responseBody`, { ns: 'workflow' })}
</label>
<ParagraphInput

View File

@ -13,7 +13,11 @@ import { fetchWebhookUrl } from '@/service/apps'
import { checkKeys, hasDuplicateStr } from '@/utils/var'
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
export const DEFAULT_STATUS_CODE = 200
export const MAX_STATUS_CODE = 399
export const normalizeStatusCode = (statusCode: number) => Math.min(Math.max(statusCode, DEFAULT_STATUS_CODE), MAX_STATUS_CODE)
export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const { t } = useTranslation()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
@ -192,15 +196,6 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
}))
}, [inputs, setInputs])
const handleStatusCodeBlur = useCallback((statusCode: number) => {
// Only clamp when user finishes editing (on blur)
const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399)
setInputs(produce(inputs, (draft) => {
draft.status_code = clampedStatusCode
}))
}, [inputs, setInputs])
const handleResponseBodyChange = useCallback((responseBody: string) => {
setInputs(produce(inputs, (draft) => {
draft.response_body = responseBody
@ -247,10 +242,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
handleBodyChange,
handleAsyncModeChange,
handleStatusCodeChange,
handleStatusCodeBlur,
handleResponseBodyChange,
generateWebhookUrl,
}
}
export default useConfig

View File

@ -2224,14 +2224,6 @@
"count": 1
}
},
"app/components/base/input-number/index.stories.tsx": {
"no-console": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/input/index.stories.tsx": {
"no-console": {
"count": 2
@ -2426,9 +2418,6 @@
"app/components/base/param-item/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/portal-to-follow-elem/index.tsx": {
@ -3132,9 +3121,6 @@
"app/components/datasets/create/step-two/components/inputs.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/datasets/create/step-two/hooks/use-indexing-config.ts": {
@ -4069,9 +4055,6 @@
"app/components/datasets/settings/index-method/keyword-number.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/datasets/settings/option-card.tsx": {
@ -6621,7 +6604,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 4
"count": 3
}
},
"app/components/workflow/nodes/_base/components/before-run-form/bool-input.tsx": {
@ -8397,9 +8380,6 @@
"app/components/workflow/nodes/trigger-webhook/panel.tsx": {
"no-restricted-imports": {
"count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx": {

View File

@ -438,6 +438,7 @@
"operation.copyImage": "Copy Image",
"operation.create": "Create",
"operation.deSelectAll": "Deselect All",
"operation.decrement": "Decrement",
"operation.delete": "Delete",
"operation.deleteApp": "Delete App",
"operation.deleteConfirmTitle": "Delete?",
@ -451,6 +452,7 @@
"operation.imageCopied": "Image copied",
"operation.imageDownloaded": "Image downloaded",
"operation.in": "in",
"operation.increment": "Increment",
"operation.learnMore": "Learn More",
"operation.lineBreak": "Line break",
"operation.log": "Log",

View File

@ -438,6 +438,7 @@
"operation.copyImage": "画像をコピー",
"operation.create": "作成",
"operation.deSelectAll": "すべて選択解除",
"operation.decrement": "減らす",
"operation.delete": "削除",
"operation.deleteApp": "アプリを削除",
"operation.deleteConfirmTitle": "削除しますか?",
@ -451,6 +452,7 @@
"operation.imageCopied": "コピーした画像",
"operation.imageDownloaded": "画像がダウンロードされました",
"operation.in": "中",
"operation.increment": "増やす",
"operation.learnMore": "詳細はこちら",
"operation.lineBreak": "改行",
"operation.log": "ログ",

View File

@ -438,6 +438,7 @@
"operation.copyImage": "复制图片",
"operation.create": "创建",
"operation.deSelectAll": "取消全选",
"operation.decrement": "减少",
"operation.delete": "删除",
"operation.deleteApp": "删除应用",
"operation.deleteConfirmTitle": "删除?",
@ -451,6 +452,7 @@
"operation.imageCopied": "图片已复制",
"operation.imageDownloaded": "图片已下载",
"operation.in": "在",
"operation.increment": "增加",
"operation.learnMore": "了解更多",
"operation.lineBreak": "换行",
"operation.log": "日志",