Skip to content

Commit

Permalink
feat: stepper support formatter & parser (ant-design#5828)
Browse files Browse the repository at this point in the history
* refactor: base format

* feat: support format

* test: update test case

* doc: update doc & version

* doc: add more desc
  • Loading branch information
zombieJ authored Nov 24, 2022
1 parent 2e721eb commit c37c931
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 99 deletions.
2 changes: 1 addition & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module.exports = {
jsxSingleQuote: true,
quoteProps: 'preserve',
arrowParens: 'avoid',
proseWrap: 'preserve',
proseWrap: 'never',
overrides: [
{
'files': ['*.md'],
Expand Down
11 changes: 11 additions & 0 deletions src/components/stepper/demos/demo1.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export default () => {
<DemoBlock title='格式化到一位小数'>
<Stepper digits={1} />
</DemoBlock>

<DemoBlock title='自定义格式'>
<Stepper
defaultValue={93}
formatter={value => `$ ${value}`}
parser={text => parseFloat(text.replace('$', ''))}
onChange={value => {
console.log(value, typeof value)
}}
/>
</DemoBlock>
</>
)
}
60 changes: 31 additions & 29 deletions src/components/stepper/index.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>) => void` | - |
| onChange | Callback when value is changed | `(value: number \| null) => void` | - |
| onFocus | Triggered when the input get focus | `(e: React.FocusEvent<HTMLInputElement>) => 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<HTMLInputElement>) => void` | - |
| onChange | Callback when value is changed | `(value: number \| null) => void` | - |
| onFocus | Triggered when the input get focus | `(e: React.FocusEvent<HTMLInputElement>) => 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` |
60 changes: 31 additions & 29 deletions src/components/stepper/index.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>) => void` | - |
| onChange | 变化时的回调 | `(value: number \| null) => void` | - |
| onFocus | 输入框获得焦点时触发 | `(e: React.FocusEvent<HTMLInputElement>) => 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<HTMLInputElement>) => void` | - |
| onChange | 变化时的回调 | `(value: number \| null) => void` | - |
| onFocus | 输入框获得焦点时触发 | `(e: React.FocusEvent<HTMLInputElement>) => 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` |
98 changes: 58 additions & 40 deletions src/components/stepper/stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,6 +34,10 @@ export type StepperProps = Pick<InputProps, 'onFocus' | 'onBlur'> &
digits?: number
disabled?: boolean
inputReadOnly?: boolean

// Format & Parse
parser?: (text: string) => number
formatter?: (value?: number) => string
} & NativeProps<
| '--height'
| '--input-width'
Expand All @@ -73,32 +63,46 @@ const defaultProps = {

export const Stepper: FC<StepperProps> = 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<number | null>(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)
}

// >>>>> Input
const handleInputChange = (v: string) => {
setInputValue(v)
const value = convertTextToValue(v)
const value = parseValue(v)
if (value === null) {
if (props.allowEmpty) {
setValue(null)
Expand All @@ -110,6 +114,32 @@ export const Stepper: FC<StepperProps> = p => {
}
}

// ============================== Focus ===============================
const [focused, setFocused] = useState(false)
const inputRef = React.useRef<InputRef>(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(
Expand Down Expand Up @@ -145,25 +175,12 @@ export const Stepper: FC<StepperProps> = 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,
<div
className={classNames(classPrefix, {
[`${classPrefix}-active`]: hasFocus,
[`${classPrefix}-active`]: focused,
})}
>
<Button
Expand All @@ -179,9 +196,10 @@ export const Stepper: FC<StepperProps> = p => {
</Button>
<div className={`${classPrefix}-middle`}>
<Input
ref={inputRef}
className={`${classPrefix}-input`}
onFocus={e => {
setHasFocus(true)
triggerFocus(true)
props.onFocus?.(e)
}}
value={inputValue}
Expand All @@ -190,7 +208,7 @@ export const Stepper: FC<StepperProps> = p => {
}}
disabled={disabled}
onBlur={e => {
setHasFocus(false)
triggerFocus(false)
props.onBlur?.(e)
}}
readOnly={inputReadOnly}
Expand Down
25 changes: 25 additions & 0 deletions src/components/stepper/tests/stepper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Stepper formatter={formatter} parser={parser} />
)

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

0 comments on commit c37c931

Please sign in to comment.