Fix: Fix: The minimum value for the "Suggested text block size" input box is set to 1. (#14246)

### What problem does this PR solve?

Fix: The minimum value for the "Suggested text block size" input box is
set to 1.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
balibabu
2026-04-21 14:06:36 +08:00
committed by GitHub
parent b3891ba6a4
commit 78b800e685
4 changed files with 240 additions and 105 deletions

View File

@@ -2,6 +2,7 @@ import { useTranslate } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import NumberInput from '../originui/number-input';
import { SingleFormSlider } from '../ui/dual-range-slider';
import {
FormControl,
@@ -10,7 +11,6 @@ import {
FormLabel,
FormMessage,
} from '../ui/form';
import { NumberInput } from '../ui/input';
import { Switch } from '../ui/switch';
type SliderInputSwitchFormFieldProps = {
@@ -95,14 +95,12 @@ export const SliderInputSwitchFormField = forwardRef<
<FormControl>
<NumberInput
disabled={disabled}
className={cn(
'h-6 w-10 p-1 border border-border-button rounded-sm',
numberInputClassName,
)}
className={cn('h-6 w-16', numberInputClassName)}
max={max}
min={min}
step={step}
{...field}
hideIcons
onChange={(value: number) => {
onChange?.(value);
field.onChange(value);

View File

@@ -1,4 +1,5 @@
import { isNumber, trim } from 'lodash';
import { cn } from '@/lib/utils';
import { isNumber, omit, trim } from 'lodash';
import { MinusIcon, PlusIcon } from 'lucide-react';
import React, {
FocusEventHandler,
@@ -18,121 +19,155 @@ interface NumberInputProps {
height?: number | string;
min?: number;
max?: number;
hideIcons?: boolean;
inputClassName?: string;
}
const NumberInput = forwardRef<HTMLInputElement, InputProps & NumberInputProps>(
function NumberInput(
{
className,
value: initialValue,
onChange,
height,
min = 0,
max = Infinity,
},
ref,
) {
const [value, setValue] = useState<number | ''>(() => {
return initialValue ?? 0;
});
const NumberInput = forwardRef<
HTMLInputElement,
Omit<InputProps, 'onChange' | 'value'> & NumberInputProps
>(function NumberInput(
{
className,
value: initialValue,
onChange,
height,
min = 0,
max = Infinity,
hideIcons = false,
inputClassName,
...props
},
ref,
) {
const [value, setValue] = useState<number | ''>(() => {
return initialValue ?? 0;
});
const valueRef = useRef<number>();
const valueRef = useRef<number>();
useEffect(() => {
if (initialValue !== undefined) {
setValue(initialValue);
}
}, [initialValue]);
useEffect(() => {
if (initialValue !== undefined) {
setValue(initialValue);
}
}, [initialValue]);
const handleDecrement = () => {
if (isNumber(value) && value > min) {
setValue(value - 1);
onChange?.(value - 1);
}
};
const handleDecrement = () => {
if (isNumber(value) && value > min) {
setValue(value - 1);
onChange?.(value - 1);
}
};
const handleIncrement = () => {
if (!isNumber(value)) {
return;
}
if (value > max - 1) {
return;
}
setValue(value + 1);
onChange?.(value + 1);
};
const handleIncrement = () => {
if (!isNumber(value)) {
return;
}
if (value > max - 1) {
return;
}
setValue(value + 1);
onChange?.(value + 1);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const currentValue = e.target.value;
const newValue = Number(currentValue);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const currentValue = e.target.value;
const newValue = Number(currentValue);
if (trim(currentValue) === '') {
if (isNumber(value)) {
valueRef.current = value;
}
setValue('');
return;
}
if (!isNaN(newValue)) {
if (newValue > max || newValue < min) {
return;
}
setValue(newValue);
onChange?.(newValue);
}
};
const handleBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
if (trim(currentValue) === '') {
if (isNumber(value)) {
onChange?.(value);
} else {
const previousValue = valueRef.current ?? min;
setValue(previousValue);
onChange?.(previousValue);
valueRef.current = value;
}
}, [min, onChange, value]);
setValue('');
return;
}
const style = useMemo(
() => ({
height: height ? `${height.toString().replace('px', '')}px` : 'auto',
}),
[height],
);
return (
if (!isNaN(newValue)) {
if (newValue > max || newValue < min) {
return;
}
setValue(newValue);
onChange?.(newValue);
}
};
const handleBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
if (isNumber(value)) {
onChange?.(value);
} else {
const previousValue = valueRef.current ?? min;
setValue(previousValue);
onChange?.(previousValue);
}
}, [min, onChange, value]);
const style = useMemo(
() => ({
height: height ? `${height.toString().replace('px', '')}px` : 'auto',
}),
[height],
);
return (
<>
<style>{`
.number-input-hide-spin::-webkit-inner-spin-button,
.number-input-hide-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.number-input-hide-spin[type='number'] {
-moz-appearance: textfield;
}
`}</style>
<div
className={`flex h-10 items-center space-x-2 border-[1px] rounded-lg w-[150px] ${className || ''}`}
className={cn(
`flex h-10 items-center space-x-2 border-[1px] rounded-lg w-[150px]`,
className,
)}
style={style}
ref={ref}
>
<button
type="button"
className="w-10 p-2 focus:outline-none border-r-[1px]"
onClick={handleDecrement}
style={style}
>
<MinusIcon size={16} aria-hidden="true" />
</button>
{hideIcons || (
<button
type="button"
className="w-10 p-2 focus:outline-none border-r-[1px]"
onClick={handleDecrement}
style={style}
>
<MinusIcon size={16} aria-hidden="true" />
</button>
)}
<input
type="text"
type="number"
value={value}
onChange={handleChange}
onBlur={handleBlur}
className="w-full flex-1 text-center bg-transparent focus:outline-none"
className={cn(
'w-full flex-1 text-center bg-transparent focus-visible:outline-none number-input-hide-spin',
'disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
{
'focus-visible:ring-1 focus-visible:ring-accent-primary rounded-lg':
hideIcons,
},
inputClassName,
)}
style={style}
min={min}
{...omit(props, ['prefix', 'suffix'])}
/>
<button
type="button"
className="w-10 p-2 focus:outline-none border-l-[1px]"
onClick={handleIncrement}
style={style}
>
<PlusIcon size={16} aria-hidden="true" />
</button>
{hideIcons || (
<button
type="button"
className="w-10 p-2 focus:outline-none border-l-[1px]"
onClick={handleIncrement}
style={style}
>
<PlusIcon size={16} aria-hidden="true" />
</button>
)}
</div>
);
},
);
</>
);
});
export default NumberInput;

View File

@@ -2,6 +2,7 @@ import { FormLayout } from '@/constants/form';
import { cn } from '@/lib/utils';
import { forwardRef, ReactNode, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import NumberInput from './originui/number-input';
import { SingleFormSlider } from './ui/dual-range-slider';
import {
FormControl,
@@ -10,7 +11,6 @@ import {
FormLabel,
FormMessage,
} from './ui/form';
import { NumberInput } from './ui/input';
export type FormLayoutType = {
layout?: FormLayout;
@@ -105,13 +105,14 @@ export const SliderInputFormField = forwardRef<
<FormControl>
<NumberInput
className={cn(
'h-6 w-10 p-0 text-center bg-bg-input border border-border-button text-text-secondary',
'h-6 w-16 p-0 text-center bg-bg-input border border-border-button text-text-secondary',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
numberInputClassName,
)}
max={displayMax}
min={displayMin}
step={displayStep}
hideIcons
value={
percentage ? (field.value * 100).toFixed(0) : field.value
}

View File

@@ -43,8 +43,9 @@ function MyComponent() {
### Features
- Increment/decrement buttons for easy value adjustment
- Keyboard input validation (only allows numeric input)
- Customizable height and styling
- Non-negative number validation
- Customizable height and styling (wrapper and input)
- Min/max value constraints
- Option to hide increment/decrement buttons
- Responsive design with Tailwind CSS
`,
},
@@ -63,13 +64,30 @@ function MyComponent() {
control: false,
},
height: {
description: 'Custom height for the input component',
description:
'Custom height for the input component (number or string with px)',
control: { type: 'text' },
},
className: {
description: 'Additional CSS classes for styling',
description: 'Additional CSS classes for the wrapper',
control: { type: 'text' },
},
inputClassName: {
description: 'Additional CSS classes for the input element',
control: { type: 'text' },
},
min: {
description: 'Minimum allowed value',
control: { type: 'number' },
},
max: {
description: 'Maximum allowed value',
control: { type: 'number' },
},
hideIcons: {
description: 'Hide the increment/decrement buttons',
control: { type: 'boolean' },
},
},
// Use `fn` to spy on the onChange arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onChange: fn() },
@@ -182,3 +200,86 @@ Shows the number input with custom CSS classes for styling.
},
tags: ['!dev'],
};
export const WithMinMax: Story = {
args: {
value: 5,
min: 0,
max: 10,
},
parameters: {
docs: {
description: {
story: `
### With Min/Max Constraints
Shows the number input with minimum and maximum value constraints. Values outside the range are rejected.
\`\`\`tsx
<NumberInput
value={5}
min={0}
max={10}
onChange={(value) => console.log('Value changed:', value)}
/>
\`\`\`
`,
},
},
},
tags: ['!dev'],
};
export const HideIcons: Story = {
args: {
value: 7,
hideIcons: true,
},
parameters: {
docs: {
description: {
story: `
### Without Icons
Shows the number input with increment/decrement buttons hidden, leaving only the text input field.
\`\`\`tsx
<NumberInput
value={7}
hideIcons
onChange={(value) => console.log('Value changed:', value)}
/>
\`\`\`
`,
},
},
},
tags: ['!dev'],
};
export const WithInputClassName: Story = {
args: {
value: 4,
inputClassName: 'text-red-500 font-bold',
},
parameters: {
docs: {
description: {
story: `
### With Input Class Name
Shows the number input with custom CSS classes applied directly to the input element.
\`\`\`tsx
<NumberInput
value={4}
inputClassName="text-red-500 font-bold"
onChange={(value) => console.log('Value changed:', value)}
/>
\`\`\`
`,
},
},
},
tags: ['!dev'],
};