Repository: ant-design/ant-design-mobile
Pull request #5828 (2022-11-24 08:12:12)

feat: stepper support `formatter` & `parser`
![Kapture 2022-11-24 at 16 11 13](https://user-images.githubusercontent.com/5378891/203728239-77c0b9ea-4a73-4a8c-a109-2fb82ab55a2b.gif)


github-actions[bot]: **Size Change:** +158 B (0%) 

**Total Size:** 348 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `./lib/bundle/antd-mobile.compatible.umd.js` | 184 kB | +78 B (0%) |
| `./lib/bundle/antd-mobile.umd.js` | 148 kB | +80 B (0%) |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `./lib/bundle/css-vars-patch.css` | 1.4 kB |
| `./lib/bundle/style.css` | 14.7 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
codecov[bot]: # [Codecov](https://codecov.io/gh/ant-design/ant-design-mobile/pull/5828?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) Report
Base: **90.09**% // Head: **90.10**% // Increases project coverage by **`+0.00%`** :tada:
> Coverage data is based on head [(`f442187`)](https://codecov.io/gh/ant-design/ant-design-mobile/pull/5828?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) compared to base [(`2e721eb`)](https://codecov.io/gh/ant-design/ant-design-mobile/commit/2e721eb232f4acab29153344e6fa63fcc45b76c9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           feature    #5828   +/-   ##
========================================
  Coverage    90.09%   90.10%           
========================================
  Files          297      297           
  Lines         6431     6437    +6     
  Branches      1594     1599    +5     
========================================
+ Hits          5794     5800    +6     
  Misses         584      584           
  Partials        53       53           
```


| [Impacted Files](https://codecov.io/gh/ant-design/ant-design-mobile/pull/5828?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design) | Coverage Δ | |
|---|---|---|
| [src/components/stepper/stepper.tsx](https://codecov.io/gh/ant-design/ant-design-mobile/pull/5828/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design#diff-c3JjL2NvbXBvbmVudHMvc3RlcHBlci9zdGVwcGVyLnRzeA==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/ant-design/ant-design-mobile/pull/5828?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ant-design).


Patch:
diff --git a/.prettierrc.js b/.prettierrc.js
index 7fbbb5b4d..47fdc02f3 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 02fd3b6d3..c7aeaa77d 100644
--- a/src/components/stepper/demos/demo1.tsx
+++ b/src/components/stepper/demos/demo1.tsx
@@ -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>
     </>
   )
 }
diff --git a/src/components/stepper/index.en.md b/src/components/stepper/index.en.md
index 897b7ccb7..136cea40b 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<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` |
diff --git a/src/components/stepper/index.zh.md b/src/components/stepper/index.zh.md
index 6685bc2e5..e4790dad0 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<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` |
diff --git a/src/components/stepper/stepper.tsx b/src/components/stepper/stepper.tsx
index 481780759..b0fc94fc4 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<InputProps, 'onFocus' | 'onBlur'> &
     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<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)
   }
@@ -98,7 +102,7 @@ export const Stepper: FC<StepperProps> = 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<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(
@@ -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
@@ -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}
@@ -190,7 +208,7 @@ export const Stepper: FC<StepperProps> = p => {
           }}
           disabled={disabled}
           onBlur={e => {
-            setHasFocus(false)
+            triggerFocus(false)
             props.onBlur?.(e)
           }}
           readOnly={inputReadOnly}


Test patch:
diff --git a/src/components/stepper/tests/stepper.test.tsx b/src/components/stepper/tests/stepper.test.tsx
index dd446835b..0e2023ec2 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(
+      <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')
+  })
 })