diff --git a/web/app/components/app/configuration/config-vision/index.spec.tsx b/web/app/components/app/configuration/config-vision/index.spec.tsx
index 5fc7648bea..0c6e1346ce 100644
--- a/web/app/components/app/configuration/config-vision/index.spec.tsx
+++ b/web/app/components/app/configuration/config-vision/index.spec.tsx
@@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
})
render()
- const input = screen.getByRole('spinbutton') as HTMLInputElement
+ const input = screen.getByRole('textbox') as HTMLInputElement
fireEvent.change(input, { target: { value: '4' } })
const updatedFile = getLatestFileConfig()
diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx
index 67d59f2706..7904159109 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx
@@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0])
await waitFor(() => {
- const [topKInput] = dialogScope.getAllByRole('spinbutton')
- expect(topKInput).toHaveValue(5)
+ const [topKInput] = dialogScope.getAllByRole('textbox')
+ expect(topKInput).toHaveValue('5')
})
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
@@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
- const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
+ const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
- expect(reopenedTopKInput).toHaveValue(5)
+ expect(reopenedTopKInput).toHaveValue('5')
})
it('should discard changes when cancel is clicked', async () => {
@@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0])
await waitFor(() => {
- const [topKInput] = dialogScope.getAllByRole('spinbutton')
- expect(topKInput).toHaveValue(5)
+ const [topKInput] = dialogScope.getAllByRole('textbox')
+ expect(topKInput).toHaveValue('5')
})
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
@@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
- const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
+ const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
- expect(reopenedTopKInput).toHaveValue(4)
+ expect(reopenedTopKInput).toHaveValue('4')
})
it('should prevent saving when rerank model is required but invalid', async () => {
diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx
index 049e19d75e..84c02f3327 100644
--- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx
+++ b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx
@@ -22,7 +22,7 @@ describe('NumberInputField', () => {
it('should render current number value', () => {
render()
- expect(screen.getByDisplayValue('2')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('2')
})
it('should update value when users click increment', () => {
diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx
index 7de473e4c8..1d7734f670 100644
--- a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx
+++ b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx
@@ -45,7 +45,7 @@ describe('BaseField', () => {
it('should render a number input when configured as number input', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('Age')).toBeInTheDocument()
})
diff --git a/web/app/components/base/input-number/__tests__/index.spec.tsx b/web/app/components/base/input-number/__tests__/index.spec.tsx
index 53e49a51ed..6056bbf5c0 100644
--- a/web/app/components/base/input-number/__tests__/index.spec.tsx
+++ b/web/app/components/base/input-number/__tests__/index.spec.tsx
@@ -13,7 +13,7 @@ describe('InputNumber Component', () => {
it('renders input with default values', () => {
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
@@ -60,7 +60,7 @@ describe('InputNumber Component', () => {
it('handles direct input changes', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42)
@@ -69,38 +69,25 @@ describe('InputNumber Component', () => {
it('handles empty input', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(0)
})
- it('does not call onChange when parsed value is NaN', () => {
+ it('does not call onChange when input is not parseable', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
- const originalNumber = globalThis.Number
- const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => {
- if (val === '123') {
- return Number.NaN
- }
- return originalNumber(val)
- })
-
- try {
- fireEvent.change(input, { target: { value: '123' } })
- expect(onChange).not.toHaveBeenCalled()
- }
- finally {
- numberSpy.mockRestore()
- }
+ 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()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '11' } })
@@ -141,7 +128,7 @@ describe('InputNumber Component', () => {
it('disables controls when disabled prop is true', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
@@ -211,6 +198,16 @@ describe('InputNumber Component', () => {
expect(onChange).not.toHaveBeenCalled()
})
+ it('uses fallback step guard when step is any', async () => {
+ const user = userEvent.setup()
+ const onChange = vi.fn()
+ render()
+ 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()
@@ -244,7 +241,7 @@ describe('InputNumber Component', () => {
it('validates input against max constraint', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '15' } })
expect(onChange).not.toHaveBeenCalled()
@@ -253,7 +250,7 @@ describe('InputNumber Component', () => {
it('validates input against min constraint', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '2' } })
expect(onChange).not.toHaveBeenCalled()
@@ -262,7 +259,7 @@ describe('InputNumber Component', () => {
it('accepts input within min and max constraints', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '50' } })
expect(onChange).toHaveBeenCalledWith(50)
@@ -296,6 +293,25 @@ describe('InputNumber Component', () => {
expect(wrapper).toHaveClass(wrapClassName)
})
+ it('applies wrapperClassName to outer div for Input compatibility', () => {
+ const onChange = vi.fn()
+ const wrapperClassName = 'custom-input-wrapper'
+ render()
+
+ 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()
+
+ 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'
@@ -327,7 +343,7 @@ describe('InputNumber Component', () => {
it('handles zero as a valid input', () => {
const onChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(0)
diff --git a/web/app/components/base/input-number/index.tsx b/web/app/components/base/input-number/index.tsx
index 102ebfeda1..42aec3f742 100644
--- a/web/app/components/base/input-number/index.tsx
+++ b/web/app/components/base/input-number/index.tsx
@@ -1,10 +1,23 @@
-import type { FC } from 'react'
-import type { InputProps } from '../input'
+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'
-import Input from '../input'
-export type InputNumberProps = {
+type InputNumberInputProps = Omit<
+ InputHTMLAttributes,
+ 'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
+>
+
+export type InputNumberProps = InputNumberInputProps & {
unit?: string
value?: number
onChange: (value: number) => void
@@ -12,19 +25,69 @@ export type InputNumberProps = {
size?: 'regular' | 'large'
max?: number
min?: number
+ step?: number | 'any'
defaultValue?: number
disabled?: boolean
wrapClassName?: string
+ wrapperClassName?: string
+ styleCss?: CSSProperties
controlWrapClassName?: string
controlClassName?: string
-} & Omit
+ type?: 'number'
+}
+
+const STEPPER_REASONS = new Set([
+ '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 = (props) => {
const {
unit,
className,
+ wrapperClassName,
+ styleCss,
onChange,
- amount = 1,
+ amount,
value,
size = 'regular',
max,
@@ -34,96 +97,97 @@ export const InputNumber: FC = (props) => {
controlWrapClassName,
controlClassName,
disabled,
+ step,
+ id,
+ name,
+ readOnly,
+ required,
+ type: _type,
...rest
} = props
- const isValidValue = useCallback((v: number) => {
- if (typeof max === 'number' && v > max)
- return false
- return !(typeof min === 'number' && v < min)
- }, [max, min])
+ const resolvedStep = resolveStep(amount, step)
+ const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
- const inc = () => {
- /* v8 ignore next 2 - @preserve */
- if (disabled)
- return
-
- if (value === undefined) {
+ const handleValueChange = useCallback((
+ nextValue: number | null,
+ eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
+ ) => {
+ if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
onChange(defaultValue ?? 0)
return
}
- const newValue = value + amount
- if (!isValidValue(newValue))
- return
- onChange(newValue)
- }
- const dec = () => {
- /* v8 ignore next 2 - @preserve */
- if (disabled)
- return
- if (value === undefined) {
- onChange(defaultValue ?? 0)
- return
- }
- const newValue = value - amount
- if (!isValidValue(newValue))
- return
- onChange(newValue)
- }
-
- const handleInputChange = useCallback((e: React.ChangeEvent) => {
- if (e.target.value === '') {
+ if (nextValue === null) {
onChange(0)
return
}
- const parsed = Number(e.target.value)
- if (Number.isNaN(parsed))
+
+ if (exceedsStepBounds({
+ value,
+ reason: eventDetails.reason,
+ stepAmount,
+ min,
+ max,
+ })) {
+ return
+ }
+
+ if (!isValueWithinBounds(nextValue, min, max))
return
- if (!isValidValue(parsed))
- return
- onChange(parsed)
- }, [isValidValue, onChange])
+ onChange(nextValue)
+ }, [defaultValue, max, min, onChange, stepAmount, value])
return (
-
-
+
-
-
-
-
+
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx
index 60bcbebcf9..b18c10216d 100644
--- a/web/app/components/base/param-item/__tests__/index.spec.tsx
+++ b/web/app/components/base/param-item/__tests__/index.spec.tsx
@@ -53,7 +53,7 @@ describe('ParamItem', () => {
it('should render InputNumber and Slider', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -68,7 +68,7 @@ describe('ParamItem', () => {
it('should disable InputNumber when enable is false', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeDisabled()
+ expect(screen.getByRole('textbox')).toBeDisabled()
})
it('should disable Slider when enable is false', () => {
@@ -104,7 +104,7 @@ describe('ParamItem', () => {
}
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '0.8')
@@ -166,14 +166,10 @@ describe('ParamItem', () => {
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
- it('should use default step of 0.1 and min of 0 when not provided', () => {
+ it('should expose default minimum of 0 when min is not provided', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- // Component renders without error with default step/min
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
- expect(input).toHaveAttribute('step', '0.1')
- expect(input).toHaveAttribute('min', '0')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
})
})
diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx
index d59768dacb..026908fa9e 100644
--- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx
+++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx
@@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
it('should render InputNumber and Slider', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => {
it('should disable controls when enable is false', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeDisabled()
+ expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -70,23 +70,19 @@ describe('ScoreThresholdItem', () => {
describe('Value Clamping', () => {
it('should clamp values to minimum of 0', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('min', '0')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
it('should clamp values to maximum of 1', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('max', '1')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
it('should use step of 0.01', () => {
- render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('step', '0.01')
+ render()
+ expect(screen.getByRole('textbox')).toHaveValue('0.5')
})
it('should call onChange with rounded value when input changes', async () => {
@@ -107,7 +103,7 @@ describe('ScoreThresholdItem', () => {
}
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '0.55')
@@ -138,8 +134,8 @@ describe('ScoreThresholdItem', () => {
it('should clamp to max=1 when value exceeds maximum', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(1)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('1')
})
})
})
diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx
index 177b51e768..1b8555213b 100644
--- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx
+++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx
@@ -36,7 +36,7 @@ describe('TopKItem', () => {
it('should render InputNumber and Slider', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -51,7 +51,7 @@ describe('TopKItem', () => {
it('should disable controls when enable is false', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeDisabled()
+ expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -59,23 +59,20 @@ describe('TopKItem', () => {
describe('Value Limits', () => {
it('should use step of 1', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('step', '1')
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('2')
})
it('should use minimum of 1', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('min', '1')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
it('should use maximum from env (10)', () => {
render()
- const input = screen.getByRole('spinbutton')
-
- expect(input).toHaveAttribute('max', '10')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
it('should render slider with max >= 5 so no scaling is applied', () => {
diff --git a/web/app/components/base/ui/number-field/__tests__/index.spec.tsx b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..cf0a9a2562
--- /dev/null
+++ b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx
@@ -0,0 +1,113 @@
+import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
+import { render, screen } from '@testing-library/react'
+import {
+ NumberField,
+ NumberFieldControls,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+ NumberFieldUnit,
+} from '../index'
+
+describe('NumberField wrapper', () => {
+ 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(
+
+
+
+
+ ,
+ )
+
+ 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(
+
+
+
+ ms
+
+
+
+
+
+ ,
+ )
+
+ 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' })
+
+ 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')
+ })
+ })
+
+ describe('Passthrough props', () => {
+ it('should forward passthrough props and custom classes to controls and buttons', () => {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ )
+
+ 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).toHaveClass('custom-controls')
+ expect(increment).toHaveClass('custom-increment')
+ expect(increment).toHaveAttribute('data-track-id', 'increment-track')
+ expect(decrement).toHaveClass('custom-decrement')
+ expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
+ })
+ })
+})
diff --git a/web/app/components/base/ui/number-field/index.tsx b/web/app/components/base/ui/number-field/index.tsx
new file mode 100644
index 0000000000..9d58fc9982
--- /dev/null
+++ b/web/app/components/base/ui/number-field/index.tsx
@@ -0,0 +1,211 @@
+'use client'
+
+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 { cn } from '@/utils/classnames'
+
+export const NumberField = BaseNumberField.Root
+
+export const numberFieldGroupVariants = cva(
+ [
+ 'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]',
+ 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
+ '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',
+ ],
+ {
+ variants: {
+ size: {
+ regular: 'radius-md',
+ large: 'radius-lg',
+ },
+ },
+ defaultVariants: {
+ size: 'regular',
+ },
+ },
+)
+
+type NumberFieldGroupProps = React.ComponentPropsWithoutRef & VariantProps
+
+export function NumberFieldGroup({
+ className,
+ size = 'regular',
+ ...props
+}: NumberFieldGroupProps) {
+ return (
+
+ )
+}
+
+export const numberFieldInputVariants = cva(
+ [
+ 'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none',
+ 'placeholder:text-components-input-text-placeholder',
+ 'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
+ 'data-[readonly]:cursor-default',
+ ],
+ {
+ variants: {
+ size: {
+ regular: 'px-3 py-[7px] system-sm-regular',
+ large: 'px-4 py-2 system-md-regular',
+ },
+ },
+ defaultVariants: {
+ size: 'regular',
+ },
+ },
+)
+
+type NumberFieldInputProps = Omit, 'size'> & VariantProps
+
+export function NumberFieldInput({
+ className,
+ size = 'regular',
+ ...props
+}: NumberFieldInputProps) {
+ return (
+
+ )
+}
+
+export const numberFieldUnitVariants = cva(
+ 'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular',
+ {
+ variants: {
+ size: {
+ regular: 'pr-2',
+ large: 'pr-2.5',
+ },
+ },
+ defaultVariants: {
+ size: 'regular',
+ },
+ },
+)
+
+type NumberFieldUnitProps = React.HTMLAttributes & VariantProps
+
+export function NumberFieldUnit({
+ className,
+ size = 'regular',
+ ...props
+}: NumberFieldUnitProps) {
+ return (
+
+ )
+}
+
+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
+
+export function NumberFieldControls({
+ className,
+ ...props
+}: NumberFieldControlsProps) {
+ return (
+
+ )
+}
+
+export const numberFieldControlButtonVariants = cva(
+ [
+ 'flex 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',
+ 'motion-reduce:transition-none',
+ ],
+ {
+ variants: {
+ size: {
+ regular: '',
+ large: '',
+ },
+ direction: {
+ increment: '',
+ decrement: '',
+ },
+ },
+ compoundVariants: [
+ {
+ size: 'regular',
+ direction: 'increment',
+ className: 'pt-1',
+ },
+ {
+ size: 'regular',
+ direction: 'decrement',
+ className: 'pb-1',
+ },
+ {
+ size: 'large',
+ direction: 'increment',
+ className: 'pt-1.5',
+ },
+ {
+ size: 'large',
+ direction: 'decrement',
+ className: 'pb-1.5',
+ },
+ ],
+ defaultVariants: {
+ size: 'regular',
+ direction: 'increment',
+ },
+ },
+)
+
+type NumberFieldButtonVariantProps = Omit<
+ VariantProps,
+ 'direction'
+>
+
+type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps
+
+export function NumberFieldIncrement({
+ className,
+ size = 'regular',
+ ...props
+}: NumberFieldButtonProps) {
+ return (
+
+ )
+}
+
+export function NumberFieldDecrement({
+ className,
+ size = 'regular',
+ ...props
+}: NumberFieldButtonProps) {
+ return (
+
+ )
+}
diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx
index e48e87560c..aeeef838f4 100644
--- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx
+++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx
@@ -47,19 +47,19 @@ describe('MaxLengthInput', () => {
it('should render number input', () => {
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render()
- expect(screen.getByDisplayValue('500')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('500')
})
it('should have min of 1', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveAttribute('min', '1')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
})
@@ -75,18 +75,18 @@ describe('OverlapInput', () => {
it('should render number input', () => {
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render()
- expect(screen.getByDisplayValue('50')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('50')
})
it('should have min of 1', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveAttribute('min', '1')
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
})
})
diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
index b8aa8b33d7..213fe30ee3 100644
--- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
@@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>,
)
- // The TopKItem should render an input
- const inputs = screen.getAllByRole('spinbutton')
+ // The TopKItem renders the visible number-field input as a textbox.
+ const inputs = screen.getAllByRole('textbox')
const topKInput = inputs[0]
fireEvent.change(topKInput, { target: { value: '8' } })
@@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>,
)
- // The ScoreThresholdItem should render an input
- const inputs = screen.getAllByRole('spinbutton')
+ // The ScoreThresholdItem renders the visible number-field input as a textbox.
+ const inputs = screen.getAllByRole('textbox')
const scoreThresholdInput = inputs[1]
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx
index debfa63dc7..bd482a5058 100644
--- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx
+++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx
@@ -43,8 +43,9 @@ describe('InputCombined', () => {
render(
,
)
- const input = screen.getByDisplayValue('42')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
+ expect(input).toHaveValue('42')
})
it('should render date picker for time type', () => {
@@ -96,7 +97,7 @@ describe('InputCombined', () => {
,
)
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled()
@@ -108,7 +109,7 @@ describe('InputCombined', () => {
,
)
- expect(screen.getByDisplayValue('999')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('999')
})
it('should apply readOnly prop to number input', () => {
@@ -117,7 +118,7 @@ describe('InputCombined', () => {
,
)
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('readonly')
})
})
@@ -186,7 +187,7 @@ describe('InputCombined', () => {
,
)
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
})
@@ -208,7 +209,7 @@ describe('InputCombined', () => {
,
)
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toHaveClass('rounded-l-md')
})
})
@@ -230,7 +231,7 @@ describe('InputCombined', () => {
,
)
- expect(screen.getByDisplayValue('0')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('0')
})
it('should handle negative number', () => {
@@ -239,7 +240,7 @@ describe('InputCombined', () => {
,
)
- expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toHaveValue('-100')
})
it('should handle special characters in string', () => {
@@ -263,7 +264,7 @@ describe('InputCombined', () => {
,
)
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
})
diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx
index dbdb9cf6f1..d0c75e4199 100644
--- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx
@@ -129,15 +129,15 @@ describe('IndexMethod', () => {
it('should pass keywordNumber to KeywordNumber component', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(25)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('25')
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled()
@@ -192,14 +192,14 @@ describe('IndexMethod', () => {
it('should handle keywordNumber of 0', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(0)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('0')
})
it('should handle max keywordNumber', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(50)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('50')
})
})
})
diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx
index 42d3b953f5..eb853014b3 100644
--- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx
+++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx
@@ -38,15 +38,15 @@ describe('KeyWordNumber', () => {
it('should render input number field', () => {
render()
- expect(screen.getByRole('spinbutton')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(25)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('25')
})
it('should display different keywordNumber values', () => {
@@ -54,8 +54,8 @@ describe('KeyWordNumber', () => {
values.forEach((value) => {
const { unmount } = render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(value)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue(String(value))
unmount()
})
})
@@ -82,7 +82,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled()
@@ -92,7 +92,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
@@ -117,32 +117,32 @@ describe('KeyWordNumber', () => {
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(0)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('0')
})
it('should handle maximum value (50)', () => {
render()
- const input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(50)
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveValue('50')
})
it('should handle value updates correctly', () => {
const { rerender } = render()
- let input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(10)
+ let input = screen.getByRole('textbox')
+ expect(input).toHaveValue('10')
rerender()
- input = screen.getByRole('spinbutton')
- expect(input).toHaveValue(25)
+ input = screen.getByRole('textbox')
+ expect(input).toHaveValue('25')
})
it('should handle rapid value changes', () => {
const handleChange = vi.fn()
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
// Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } })
@@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
it('should have accessible input', () => {
render()
- const input = screen.getByRole('spinbutton')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
})