diff --git a/.prettierrc.js b/.prettierrc.js index 7fbbb5b4d3..47fdc02f3c 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,7 +7,7 @@ module.exports = { jsxSingleQuote: true, quoteProps: 'preserve', arrowParens: 'avoid', - proseWrap: 'preserve', + proseWrap: 'never', overrides: [ { 'files': ['*.md'], diff --git a/src/components/stepper/demos/demo1.tsx b/src/components/stepper/demos/demo1.tsx index 02fd3b6d31..c7aeaa77d2 100644 --- a/src/components/stepper/demos/demo1.tsx +++ b/src/components/stepper/demos/demo1.tsx @@ -29,6 +29,17 @@ export default () => { + + + `$ ${value}`} + parser={text => parseFloat(text.replace('$', ''))} + onChange={value => { + console.log(value, typeof value) + }} + /> + > ) } diff --git a/src/components/stepper/index.en.md b/src/components/stepper/index.en.md index 897b7ccb75..136cea40b7 100644 --- a/src/components/stepper/index.en.md +++ b/src/components/stepper/index.en.md @@ -16,37 +16,39 @@ It is suitable for inputting and adjusting the current value within a certain ra ### Props -| Name | Description | Type | Default | -| ------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------- | -| allowEmpty | Whether to allow empty content. | `boolean` | `false` | -| defaultValue | Default value | `number \| null` | `0` | -| digits | Format to a fixed number of digits after the decimal point, set to `0` means format to integer | `number` | - | -| disabled | Whether to disabled Stepper | `boolean` | `false` | -| inputReadOnly | Whether input readonly or not | `boolean` | `false` | -| max | Max value | `number` | - | -| min | Min value | `number` | - | -| onBlur | Triggered when the input lose focus | `(e: React.FocusEvent) => void` | - | -| onChange | Callback when value is changed | `(value: number \| null) => void` | - | -| onFocus | Triggered when the input get focus | `(e: React.FocusEvent) => void` | - | -| step | Change the number of steps each time, it can be a decimal | `number` | `1` | -| value | Current number, controlled value | `number \| null` | - | +| Name | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| allowEmpty | Whether to allow empty content. | `boolean` | `false` | +| defaultValue | Default value | `number \| null` | `0` | +| digits | Format to a fixed number of digits after the decimal point, set to `0` means format to integer. Will use `formatter` as display value when configured | `number` | - | +| disabled | Whether to disabled Stepper | `boolean` | `false` | +| formatter | Format value in input | (value?: number) => string | - | 5.26.0 | +| inputReadOnly | Whether input readonly or not | `boolean` | `false` | +| max | Max value | `number` | - | +| min | Min value | `number` | - | +| onBlur | Triggered when the input lose focus | `(e: React.FocusEvent) => void` | - | +| onChange | Callback when value is changed | `(value: number \| null) => void` | - | +| onFocus | Triggered when the input get focus | `(e: React.FocusEvent) => void` | - | +| parser | Parse input text into number which should work with `formatter` | (text: string) => number | - | 5.26.0 | +| step | Change the number of steps each time, it can be a decimal | `number` | `1` | +| value | Current number, controlled value | `number \| null` | - | When `allowEmpty` is `true`, the `value` parameter of `onChange` may be `null`, please pay attention when using it. ### CSS Variables -| Name | Description | Default | -| ------------------------- | ------------------------------------- | --------------------------- | -| --active-border | In the focus state, the border style. | `var(--border)` | -| --border | Border style. | `none` | -| --border-inner | Inner border style. | `solid 2px transparent` | -| --border-radius | Radius of the stepper. | `2px` | -| --button-background-color | Background color of the button. | `#f5f5f5` | -| --button-font-size | Font size of the button. | `15px` | -| --button-text-color | Font color of the button. | `var(--adm-color-primary)` | -| --button-width | Width of the button. | `var(--height)` | -| --height | Height of the stepper. | `28px` | -| --input-background-color | Background color of input. | `#f5f5f5` | -| --input-font-color | Font color of the input. | `var(--adm-color-text)` | -| --input-font-size | Font size of input. | `var(--adm-font-size-main)` | -| --input-width | Width of the input. | `44px` | +| Name | Description | Default | +| --- | --- | --- | +| --active-border | In the focus state, the border style. | `var(--border)` | +| --border | Border style. | `none` | +| --border-inner | Inner border style. | `solid 2px transparent` | +| --border-radius | Radius of the stepper. | `2px` | +| --button-background-color | Background color of the button. | `#f5f5f5` | +| --button-font-size | Font size of the button. | `15px` | +| --button-text-color | Font color of the button. | `var(--adm-color-primary)` | +| --button-width | Width of the button. | `var(--height)` | +| --height | Height of the stepper. | `28px` | +| --input-background-color | Background color of input. | `#f5f5f5` | +| --input-font-color | Font color of the input. | `var(--adm-color-text)` | +| --input-font-size | Font size of input. | `var(--adm-font-size-main)` | +| --input-width | Width of the input. | `44px` | diff --git a/src/components/stepper/index.zh.md b/src/components/stepper/index.zh.md index 6685bc2e50..e4790dad09 100644 --- a/src/components/stepper/index.zh.md +++ b/src/components/stepper/index.zh.md @@ -16,37 +16,39 @@ ### 属性 -| 参数 | 说明 | 类型 | 默认值 | -| ------------- | ----------------------------------------------------- | ------------------------------------------------- | ------- | -| allowEmpty | 是否允许内容为空 | `boolean` | `false` | -| defaultValue | 默认值 | `number \| null` | `0` | -| digits | 格式化到小数点后固定位数,设置为 `0` 表示格式化到整数 | `number` | - | -| disabled | 是否禁用步进器 | `boolean` | `false` | -| inputReadOnly | 输入框是否只读 | `boolean` | `false` | -| max | 最大值 | `number` | - | -| min | 最小值 | `number` | - | -| onBlur | 输入框失去焦点时触发 | `(e: React.FocusEvent) => void` | - | -| onChange | 变化时的回调 | `(value: number \| null) => void` | - | -| onFocus | 输入框获得焦点时触发 | `(e: React.FocusEvent) => void` | - | -| step | 每次改变步数,可以为小数 | `number` | `1` | -| value | 当前数,受控值 | `number \| null` | - | +| 参数 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| allowEmpty | 是否允许内容为空 | `boolean` | `false` | +| defaultValue | 默认值 | `number \| null` | `0` | +| digits | 格式化到小数点后固定位数,设置为 `0` 表示格式化到整数。配置 `formatter` 时展示会以 `formatter` 为准 | `number` | - | +| disabled | 是否禁用步进器 | `boolean` | `false` | +| formatter | 格式化展示数值 | (value?: number) => string | - | 5.26.0 | +| inputReadOnly | 输入框是否只读 | `boolean` | `false` | +| max | 最大值 | `number` | - | +| min | 最小值 | `number` | - | +| onBlur | 输入框失去焦点时触发 | `(e: React.FocusEvent) => void` | - | +| onChange | 变化时的回调 | `(value: number \| null) => void` | - | +| onFocus | 输入框获得焦点时触发 | `(e: React.FocusEvent) => void` | - | +| parser | 将输入解析为对应数字,一般配合 `formatter` 使用 | (text: string) => number | - | 5.26.0 | +| step | 每次改变步数,可以为小数 | `number` | `1` | +| value | 当前数,受控值 | `number \| null` | - | 当 `allowEmpty` 为 `true` 时,`onChange` 的 `value` 参数可能会为 `null`,在使用时请留意。 ### CSS 变量 -| 属性 | 说明 | 默认值 | -| ------------------------- | --------------------------------- | --------------------------- | -| --active-border | 输入框 Focus 状态下,四周边框样式 | `var(--border)` | -| --border | 组件四周边框的样式 | `none` | -| --border-inner | 组件内部边框的样式 | `solid 2px transparent` | -| --border-radius | 组件整体的圆角 | `2px` | -| --button-background-color | 左右两侧按钮背景颜色 | `#f5f5f5` | -| --button-font-size | 左右两侧按钮文字大小 | `15px` | -| --button-text-color | 左右两侧按钮文字颜色 | `var(--adm-color-primary)` | -| --button-width | 左右两侧按钮的宽度 | `var(--height)` | -| --height | 组件整体高度 | `28px` | -| --input-background-color | 输入框的背景颜色 | `#f5f5f5` | -| --input-font-color | 输入框文字颜色 | `var(--adm-color-text)` | -| --input-font-size | 输入框文字大小 | `var(--adm-font-size-main)` | -| --input-width | 仅输入框的宽度 | `44px` | +| 属性 | 说明 | 默认值 | +| --- | --- | --- | +| --active-border | 输入框 Focus 状态下,四周边框样式 | `var(--border)` | +| --border | 组件四周边框的样式 | `none` | +| --border-inner | 组件内部边框的样式 | `solid 2px transparent` | +| --border-radius | 组件整体的圆角 | `2px` | +| --button-background-color | 左右两侧按钮背景颜色 | `#f5f5f5` | +| --button-font-size | 左右两侧按钮文字大小 | `15px` | +| --button-text-color | 左右两侧按钮文字颜色 | `var(--adm-color-primary)` | +| --button-width | 左右两侧按钮的宽度 | `var(--height)` | +| --height | 组件整体高度 | `28px` | +| --input-background-color | 输入框的背景颜色 | `#f5f5f5` | +| --input-font-color | 输入框文字颜色 | `var(--adm-color-text)` | +| --input-font-size | 输入框文字大小 | `var(--adm-font-size-main)` | +| --input-width | 仅输入框的宽度 | `44px` | diff --git a/src/components/stepper/stepper.tsx b/src/components/stepper/stepper.tsx index 4817807598..b0fc94fc46 100644 --- a/src/components/stepper/stepper.tsx +++ b/src/components/stepper/stepper.tsx @@ -5,27 +5,13 @@ import { NativeProps, withNativeProps } from '../../utils/native-props' import { usePropsValue } from '../../utils/use-props-value' import { mergeProps } from '../../utils/with-default-props' import { bound } from '../../utils/bound' -import Input, { InputProps } from '../input' +import Input, { InputProps, InputRef } from '../input' import Button from '../button' import Big from 'big.js' import { useConfig } from '../config-provider' const classPrefix = `adm-stepper` -function convertValueToText(value: number | null, digits?: number) { - if (value === null) return '' - if (digits !== undefined) { - return value.toFixed(digits) - } else { - return value.toString() - } -} - -function convertTextToValue(text: string) { - if (text === '') return null - return parseFloat(text) -} - type ValueProps = { allowEmpty: true value?: number | null @@ -48,6 +34,10 @@ export type StepperProps = Pick & digits?: number disabled?: boolean inputReadOnly?: boolean + + // Format & Parse + parser?: (text: string) => number + formatter?: (value?: number) => string } & NativeProps< | '--height' | '--input-width' @@ -73,24 +63,38 @@ const defaultProps = { export const Stepper: FC = p => { const props = mergeProps(defaultProps, p) - const { disabled, step, max, min, inputReadOnly } = props + const { disabled, step, max, min, inputReadOnly, digits, formatter, parser } = + props const { locale } = useConfig() - // ============================== Focus =============================== - const [hasFocus, setHasFocus] = useState(false) + // ========================== Parse / Format ========================== + const parseValue = (text: string) => { + if (text === '') return null + return parser ? parser(text) : parseFloat(text) + } + + const formatValue = (value: number | null) => { + if (value === null) return '' + + if (formatter) { + return formatter(value) + } else if (digits !== undefined) { + return value.toFixed(digits) + } else { + return value.toString() + } + } // ======================== Value & InputValue ======================== const [value, setValue] = usePropsValue(props as any) - const [inputValue, setInputValue] = useState(() => - convertValueToText(value, props.digits) - ) + const [inputValue, setInputValue] = useState(() => formatValue(value)) // >>>>> Value function setValueWithCheck(v: number) { if (isNaN(v)) return let target = bound(v, props.min, props.max) - if (props.digits !== undefined) { - target = parseFloat(target.toFixed(props.digits)) + if (digits !== undefined) { + target = parseFloat(target.toFixed(digits)) } setValue(target) } @@ -98,7 +102,7 @@ export const Stepper: FC = p => { // >>>>> Input const handleInputChange = (v: string) => { setInputValue(v) - const value = convertTextToValue(v) + const value = parseValue(v) if (value === null) { if (props.allowEmpty) { setValue(null) @@ -110,6 +114,32 @@ export const Stepper: FC = p => { } } + // ============================== Focus =============================== + const [focused, setFocused] = useState(false) + const inputRef = React.useRef(null) + + function triggerFocus(nextFocus: boolean) { + setFocused(nextFocus) + + // We will convert value to original text when focus + if (nextFocus) { + setInputValue(typeof value === 'number' ? String(value) : '') + } + } + + useEffect(() => { + if (focused) { + inputRef.current?.nativeElement?.select?.() + } + }, [focused]) + + // Focus change to format value + useEffect(() => { + if (!focused) { + setInputValue(formatValue(value)) + } + }, [focused, value, digits]) + // ============================ Operations ============================ const handleMinus = () => { setValueWithCheck( @@ -145,25 +175,12 @@ export const Stepper: FC = p => { return false } - // ============================== Effect ============================== - useEffect(() => { - if (!hasFocus) { - setInputValue(convertValueToText(value, props.digits)) - } - }, [hasFocus]) - - useEffect(() => { - if (!hasFocus) { - setInputValue(convertValueToText(value, props.digits)) - } - }, [value, props.digits]) - // ============================== Render ============================== return withNativeProps( props, = p => { { - setHasFocus(true) + triggerFocus(true) props.onFocus?.(e) }} value={inputValue} @@ -190,7 +208,7 @@ export const Stepper: FC = p => { }} disabled={disabled} onBlur={e => { - setHasFocus(false) + triggerFocus(false) props.onBlur?.(e) }} readOnly={inputReadOnly} diff --git a/src/components/stepper/tests/stepper.test.tsx b/src/components/stepper/tests/stepper.test.tsx index dd446835bd..0e2023ec2a 100644 --- a/src/components/stepper/tests/stepper.test.tsx +++ b/src/components/stepper/tests/stepper.test.tsx @@ -207,4 +207,29 @@ describe('stepper', () => { expect(input.value).toBe('1') }) }) + + test('formatter & parser', () => { + const formatter = jest.fn((val?: number) => `$ ${val}`) + const parser = jest.fn((text: string) => parseFloat(text)) + + const { container } = render( + + ) + + const inputEle = container.querySelector('input') as HTMLInputElement + expect(inputEle.value).toEqual('$ 0') + + fireEvent.focus(inputEle) + expect(inputEle.value).toEqual('0') + + fireEvent.change(inputEle, { + target: { + value: 93, + }, + }) + expect(inputEle.value).toEqual('93') + + fireEvent.blur(inputEle) + expect(inputEle.value).toEqual('$ 93') + }) })