Browse Source

Merge branch 'plus/pincode' into plus/done

DaiQiangReal 1 năm trước cách đây
mục cha
commit
2b74e04009

+ 2 - 1
.eslintrc.js

@@ -43,9 +43,9 @@ module.exports = {
                 'jsx-a11y/mouse-events-have-key-events': ['warn'],
                 'jsx-a11y/mouse-events-have-key-events': ['warn'],
                 'object-curly-spacing': ['error', 'always'],
                 'object-curly-spacing': ['error', 'always'],
                 'space-before-blocks': ['error', 'always'],
                 'space-before-blocks': ['error', 'always'],
+                "space-infix-ops": "error",
                 'max-len': 'off',
                 'max-len': 'off',
                 'react/forbid-foreign-prop-types': ['error', { "allowInPropTypes": true }]
                 'react/forbid-foreign-prop-types': ['error', { "allowInPropTypes": true }]
-
             },
             },
             globals: {
             globals: {
                 "sinon": "readonly",
                 "sinon": "readonly",
@@ -119,6 +119,7 @@ module.exports = {
                 'semi-design/no-import': 'error',
                 'semi-design/no-import': 'error',
                 "space-infix-ops": ["error", { "int32Hint": false }],
                 "space-infix-ops": ["error", { "int32Hint": false }],
                 'space-before-blocks': ['error', 'always'],
                 'space-before-blocks': ['error', 'always'],
+                "space-infix-ops": "error",
                 "@typescript-eslint/type-annotation-spacing": ['error', {"after": true}],
                 "@typescript-eslint/type-annotation-spacing": ['error', {"after": true}],
                 "@typescript-eslint/member-delimiter-style": [
                 "@typescript-eslint/member-delimiter-style": [
                     "error",
                     "error",

+ 208 - 0
content/input/pincode/index.md

@@ -0,0 +1,208 @@
+---
+localeCode: zh-CN
+order: 0
+category: 输入类
+title: PinCode 验证码输入
+icon: doc-input
+width: 60%
+brief: 用于便捷直观地输入验证码
+---
+
+## 代码演示
+
+### 如何引入
+
+```jsx
+import { PinCode } from '@douyinfe/semi-ui';
+```
+
+### 基本使用
+
+```jsx live=true
+import { PinCode } from '@douyinfe/semi-ui';
+import React from 'react';
+
+function Demo() {
+    return (
+        <>
+            <PinCode
+                size={'small'}
+                defaultValue={'123456'}
+                onComplete={value => console.log('pincode: ', value)}
+                onChange={value => {
+                    console.log(value);
+                }}
+            />
+            <br />
+            <PinCode
+                size={'default'}
+                defaultValue={'123456'}
+                onComplete={value => console.log('pincode: ', value)}
+                onChange={value => {
+                    console.log(value);
+                }}
+            />
+            <br />
+            <PinCode
+                size={'large'}
+                defaultValue={'123456'}
+                onComplete={value => console.log('pincode: ', value)}
+                onChange={value => {
+                    console.log(value);
+                }}
+            />
+        </>
+    );
+}
+```
+
+### 受控
+
+使用 value 传入验证码字符串,配合 onChange 受控使用
+
+```jsx live=true
+import React from 'react';
+import { PinCode, Button } from '@douyinfe/semi-ui';
+
+function Demo() {
+    const [value, setValue] = useState('69af41');
+    return (
+        <>
+            <Button onClick={() => setValue(String(parseInt(Math.random() * 100000000)).slice(0, 6))}>
+                Set Random Value
+            </Button>
+            <br />
+            <br />
+            <PinCode
+                format={'mixed'}
+                onComplete={value => console.log('pincode: ', value)}
+                value={value}
+                onChange={v => {
+                    console.log(v);
+                    setValue(v);
+                }}
+            />
+        </>
+    );
+}
+```
+
+### 限制验证码格式
+
+#### 设置位数
+
+通过 count 设置位数,默认 6 位,下方 Demo 设置为 4 位
+
+```jsx live=true
+import React from 'react';
+import { PinCode } from '@douyinfe/semi-ui';
+
+function Demo() {
+    return (
+        <>
+            <PinCode
+                size={'large'}
+                defaultValue={'6688'}
+                count={4}
+                onComplete={value => console.log('pincode: ', value)}
+                onChange={value => {
+                    console.log(value);
+                }}
+            />
+        </>
+    );
+}
+```
+
+#### 设置字符范围
+
+使用 format 控制可输入的字符范围
+
+-   传入 "number" 只允许设置数字
+-   传入 “mixed” 允许数字和字母
+-   传入正则表达式,只允许输入可通过正则判定的字符
+-   传入函数,验证码会在输入的时候以字符为单位被依次作为参数分别单独传入进行校验,当函数返回 true 时,允许该字符被输入进 PinCode
+
+```jsx live=true
+import React from 'react';
+import { PinCode, Button, Typography } from '@douyinfe/semi-ui';
+
+function Demo() {
+    return (
+        <>
+            <Typography.Text>纯数字</Typography.Text>
+            <PinCode format={'number'} onComplete={value => console.log('pincode: ', value)} />
+            <br />
+            <Typography.Text>字母和数字</Typography.Text>
+            <PinCode format={'mixed'} onComplete={value => console.log('pincode: ', value)} />
+            <br />
+            <Typography.Text>只大写字母</Typography.Text>
+            <PinCode format={/[A-Z]/} onComplete={value => console.log('pincode: ', value)} />
+            <br />
+            <Typography.Text>只小写字母(函数判断)</Typography.Text>
+            <PinCode
+                format={char => {
+                    return /[a-z]/.test(char);
+                }}
+                onComplete={value => console.log('pincode: ', value)}
+            />
+        </>
+    );
+}
+```
+
+### 手动聚焦失焦
+
+使用 Ref 上方法 focus 与 blur,入参为对应 Input 的序号
+
+```jsx live=true
+import React from 'react';
+import { PinCode, Button } from '@douyinfe/semi-ui';
+
+function Demo() {
+    const [value, setValue] = useState('69af41');
+    const ref = useRef();
+    return (
+        <>
+            <Button onClick={() => ref.current.focus(2)}>Focus Third Input</Button>
+            <br />
+            <br />
+            <PinCode
+                format={'mixed'}
+                ref={ref}
+                onComplete={value => console.log('pincode: ', value)}
+                value={value}
+                onChange={v => {
+                    console.log(v);
+                    setValue(v);
+                }}
+            />
+        </>
+    );
+}
+```
+
+## API 参考
+
+| 属性 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| autoFocus | 是否自动聚焦到第一个元素 | boolean | true |
+| className | 类名 | string |  |
+| count | 验证码位数 | number | 6 |
+| defaultValue | 输入框内容默认值 | string |  |
+| disabled | 禁用 | boolean | false |
+| format | 验证码单个字符格式限制 | 'number'\| 'mixed‘ \| RegExp \| (char:string)=>boolean | 'number' |
+| size | 输入框大小,large、default、small | string | 'default' |
+| style | 样式 | object |  |
+| value | 输入框内容 | string |  |
+| onChange | 输入回调 | (value:string)=>void |  |
+| onComplete | 验证码所有位数输入完毕回调 | (value: string) => void |  |
+
+## Methods
+
+绑定在组件实例上的方法,可以通过 ref 调用实现某些特殊交互
+
+| 属性  | 说明                         |
+| ----- | ---------------------------- |
+| focus | 聚焦,入参为验证码第几位     |
+| blur  | 移出焦点,入参为验证码第几位 | string |

+ 13 - 0
packages/semi-foundation/pincode/constants.ts

@@ -0,0 +1,13 @@
+import { BASE_CLASS_PREFIX } from '../base/constants';
+
+const cssClasses = {
+    PREFIX: `${BASE_CLASS_PREFIX}-pincode`
+} as const;
+
+const strings = {} as const;
+
+const numbers = {
+
+} as const;
+
+export { cssClasses, strings, numbers };

+ 126 - 0
packages/semi-foundation/pincode/foundation.ts

@@ -0,0 +1,126 @@
+import BaseFoundation, { DefaultAdapter } from '../base/foundation';
+
+
+export interface PinCodeBaseProps {
+    disabled?: boolean;
+    value?: string;
+    format?: "number" | "mixed" | RegExp | ((value: string) => boolean);
+    onChange: (value: string) => void;
+    defaultValue?: string;
+    count?: number;
+    autoFocus?: boolean;
+    onComplete?: (value: string) => void
+}
+
+export interface PinCodeBaseState {
+    valueList: string[];
+    currentActiveIndex: number
+}
+
+export interface PinCodeAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    onCurrentActiveIndexChange: (index: number) => Promise<void>|void;
+    notifyValueChange: (values: string[]) => void;
+    changeSpecificInputFocusState: (index: number, state: "blur" | "focus") => void;
+    updateValueList: (newValueList: PinCodeBaseState['valueList']) => Promise<void>|void
+}
+
+
+class PinCodeFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<PinCodeAdapter<P, S>, P, S> {
+
+    constructor(adapter: PinCodeAdapter<P, S>) {
+        super({ ...adapter });
+    }
+
+    static numberReg = /^\d*$/;
+    static mixedReg = /^[0-9a-zA-Z]$/;
+
+
+    handleCurrentActiveIndexChange = (index: number, state: "focus"|"blur")=>{
+        if (state === "focus") {
+            this._adapter.onCurrentActiveIndexChange(index);
+        }
+    }
+
+    completeSingleInput = async (i: number, singleInputValue: string) => {
+        const isControlledComponent = Boolean(this.getProp("value"));
+        await this._adapter.onCurrentActiveIndexChange(i + 1);
+        const valueList = [...this.getState("valueList")];
+        valueList[i] = singleInputValue;
+        if (isControlledComponent) {
+            this._adapter.notifyValueChange(valueList);
+        } else {
+            await this.updateValueList(valueList);
+        }
+
+        const count = this.getProp('count');
+        if (i + 1 > count - 1) {
+            this._adapter.changeSpecificInputFocusState(i, "blur");
+            this.getProp("onComplete")?.(valueList.join(""));
+        } else {
+            this._adapter.changeSpecificInputFocusState(i + 1, "focus");
+        }
+
+    }
+
+    validateValue = (value: string = "") => {
+        const format = this.getProp("format") as PinCodeBaseProps['format'];
+        let validateFunction = (value: string) => true;
+        if (typeof format === "string") {
+            if (format === "number") {
+                validateFunction = (value) => value.length === 0 || PinCodeFoundation.numberReg.test(value);
+            } else if (format === "mixed") {
+                validateFunction = (value: string) => value.length === 0 || PinCodeFoundation.mixedReg.test(value);
+            }
+        } else if (format instanceof RegExp) {
+            validateFunction = (value: string) => (format as RegExp).test(value);
+        } else if (typeof format === "function") {
+            validateFunction = format;
+        }
+        return validateFunction(value);
+    }
+
+    updateValueList = async (newValueList: PinCodeBaseState['valueList']) => {
+        this._adapter.updateValueList(newValueList);
+    }
+
+    handlePaste = async (e: ClipboardEvent, startInputIndex: number)=>{
+        const textWillPaste = e.clipboardData.getData("text");
+        const count = this.getProp("count");
+        for (let i = startInputIndex, charIndex = 0;i < count && charIndex < textWillPaste.length;i++, charIndex++) {
+            const currentChar = textWillPaste[charIndex];
+            if (this.validateValue(currentChar)) {
+                await this.completeSingleInput(i, currentChar);
+            } else {
+                break;
+            }
+        }
+        e.preventDefault();
+    }
+
+    handleKeyDownOnSingleInput = (e: KeyboardEvent, index: number)=>{
+        const valueList = [...this.getState("valueList")];
+        if (e.key === "Backspace") {
+            valueList[index] = "";
+            this.updateValueList(valueList);
+            this._adapter.changeSpecificInputFocusState(Math.max(0, index - 1), "focus");
+            e.preventDefault();
+        } else if (e.key === "Delete") {
+            valueList[index] = "";
+            this.updateValueList(valueList);
+            this._adapter.changeSpecificInputFocusState(Math.min(valueList.length - 1, index + 1), "focus");
+            e.preventDefault();
+        } else if (e.key === "ArrowLeft") {
+            this._adapter.changeSpecificInputFocusState(Math.max(0, index - 1), "focus");
+            e.preventDefault();
+        } else if (e.key === "ArrowRight") {
+            this._adapter.changeSpecificInputFocusState(Math.min(valueList.length - 1, index + 1), "focus");
+            e.preventDefault();
+        }
+
+    }
+
+
+}
+
+
+export default PinCodeFoundation;

+ 41 - 0
packages/semi-foundation/pincode/pincode.scss

@@ -0,0 +1,41 @@
+@import "./variables.scss";
+
+$module: #{$prefix}-pincode;
+
+
+
+.#{$module} {
+    &-wrapper{
+        display: flex;
+
+        .#{$prefix}-input-wrapper{
+            &-small{
+                width: $width-pinCode_input_small;
+                margin-right: $spacing-pinCode_small-marginRight;
+                &:last-child{
+                    margin-right: 0px;
+                }
+            }
+
+            &-default{
+                width: $width-pinCode_input_default;
+                margin-right: $spacing-pinCode_default-marginRight;
+                &:last-child{
+                    margin-right: 0px;
+                }
+            }
+
+            &-large{
+                width: $width-pinCode_input_large;
+                margin-right: $spacing-pinCode_large-marginRight;
+                &:last-child{
+                    margin-right: 0px;
+                }
+            }
+            input{
+                padding: 0;
+                text-align: center;
+            }
+        }
+    }
+}

+ 7 - 0
packages/semi-foundation/pincode/variables.scss

@@ -0,0 +1,7 @@
+$spacing-pinCode_small-marginRight:6px;
+$spacing-pinCode_default-marginRight:8px;
+$spacing-pinCode_large-marginRight:12px;
+
+$width-pinCode_input_small:24px;
+$width-pinCode_input_default:32px;
+$width-pinCode_input_large:42px;

+ 0 - 1
packages/semi-foundation/tsconfig.json

@@ -9,7 +9,6 @@
         "lib": ["es7", "dom", "es2017"],
         "lib": ["es7", "dom", "es2017"],
         "moduleResolution": "node",
         "moduleResolution": "node",
         "noImplicitAny": false,
         "noImplicitAny": false,
-        "suppressImplicitAnyIndexErrors": true,
         "forceConsistentCasingInFileNames": true,
         "forceConsistentCasingInFileNames": true,
         "allowSyntheticDefaultImports": true,
         "allowSyntheticDefaultImports": true,
         "experimentalDecorators": true,
         "experimentalDecorators": true,

+ 6 - 0
packages/semi-ui/_base/baseComponent.tsx

@@ -84,4 +84,10 @@ export default class BaseComponent<P extends BaseProps = {}, S = {}> extends Com
     getDataAttr(props: any = this.props) {
     getDataAttr(props: any = this.props) {
         return getDataAttr(props);
         return getDataAttr(props);
     }
     }
+
+    setStateAsync = (state: Partial<S>)=>{
+        return new Promise<void>(resolve=>{
+            this.setState(state as any, resolve);
+        });
+    }
 }
 }

+ 3 - 1
packages/semi-ui/index.ts

@@ -3,7 +3,7 @@ export { default as BaseFoundation } from "@douyinfe/semi-foundation/base/founda
 export { default as BaseComponent } from "./_base/baseComponent";
 export { default as BaseComponent } from "./_base/baseComponent";
 export { default as Anchor } from './anchor';
 export { default as Anchor } from './anchor';
 export { default as AutoComplete } from './autoComplete';
 export { default as AutoComplete } from './autoComplete';
-export { default as Avatar } from './avatar';
+export { default as Avatar } from './avatar'; 
 export { default as AvatarGroup } from './avatar/avatarGroup';
 export { default as AvatarGroup } from './avatar/avatarGroup';
 export { default as BackTop } from './backtop';
 export { default as BackTop } from './backtop';
 export { default as Badge } from './badge';
 export { default as Badge } from './badge';
@@ -102,6 +102,8 @@ export {
 export { default as Image } from './image';
 export { default as Image } from './image';
 export { Preview as ImagePreview } from './image';
 export { Preview as ImagePreview } from './image';
 
 
+export { default as PinCode } from "./pincode";
+
 export { default as MarkdownRender } from "./markdownRender";
 export { default as MarkdownRender } from "./markdownRender";
 export { default as CodeHighlight } from "./codeHighlight";
 export { default as CodeHighlight } from "./codeHighlight";
 export { default as Lottie } from "./lottie";
 export { default as Lottie } from "./lottie";

+ 34 - 0
packages/semi-ui/pincode/_story/pincode.stories.js

@@ -0,0 +1,34 @@
+import React,{useState} from 'react';
+import PinCode from '../index';
+
+export default {
+    title: 'PinCode'
+}
+
+export const PinCodeBasic = () => (
+    <>
+      <PinCode size={'small'} onComplete={v=>alert(v)} onChange={v=>{
+          console.log(v)
+      }}/>
+        <PinCode size={'default'} onComplete={v=>alert(v)} onChange={v=>{
+            console.log(v)
+        }}/>
+        <PinCode size={'large'} onComplete={v=>alert(v)} onChange={v=>{
+            console.log(v)
+        }}/>
+    </>
+);
+
+export const PinCodeControl = () => {
+    const [value,setValue] = useState("123");
+    const ref = useRef();
+    return <>
+        <button onClick={()=>ref.current.focus(2)}>focus third</button>
+        <PinCode format={"mixed"}  ref={ref}
+                 onComplete={value=>console.log("pincode: ",value)}
+                 value={value} onChange={v=>{
+            console.log(v)
+            setValue(v)
+        }}/>
+    </>
+}

+ 6 - 0
packages/semi-ui/pincode/_story/pincode.stories.ts

@@ -0,0 +1,6 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+
+
+const stories = storiesOf('PinCode', module);
+

+ 137 - 0
packages/semi-ui/pincode/index.tsx

@@ -0,0 +1,137 @@
+import React, { CSSProperties, ReactElement } from 'react';
+import PropTypes from 'prop-types';
+import cls from 'classnames';
+import PinCodeFoundation, {
+    PinCodeAdapter,
+    PinCodeBaseProps,
+    PinCodeBaseState,
+} from '@douyinfe/semi-foundation/pincode/foundation';
+import { cssClasses } from '@douyinfe/semi-foundation/pincode/constants';
+import BaseComponent from '../_base/baseComponent';
+import { getDefaultPropsFromGlobalConfig } from '../_utils';
+import Input, { InputProps } from '../input';
+import "@douyinfe/semi-foundation/pincode/pincode.scss";
+
+export interface PinCodeProps extends PinCodeBaseProps {
+    className?: string;
+    style?: CSSProperties;
+    size?: InputProps['size']
+}
+
+export interface PinCodeState extends PinCodeBaseState {
+
+}
+
+class PinCode extends BaseComponent<PinCodeProps, PinCodeState> {
+
+    static __SemiComponentName__ = "PinCode";
+
+    static propTypes = {
+        value: PropTypes.string,
+        format: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.func]),
+        onChange: PropTypes.func,
+        defaultValue: PropTypes.string,
+        count: PropTypes.number,
+        className: PropTypes.string,
+        style: PropTypes.object,
+        autoFocus: PropTypes.bool,
+        onComplete: PropTypes.func,
+    };
+
+    static defaultProps = getDefaultPropsFromGlobalConfig(PinCode.__SemiComponentName__, {
+        count: 6,
+        format: "number",
+        autoFocus: true,
+    })
+
+    inputDOMList: HTMLInputElement[] = []
+    foundation: PinCodeFoundation
+
+    constructor(props: PinCodeProps) {
+        super(props);
+        this.foundation = new PinCodeFoundation(this.adapter);
+        this.state = {
+            valueList: (this.props.value || this.props.defaultValue) && (this.props.value || this.props.defaultValue).split("") || [],
+            currentActiveIndex: 0
+        };
+    }
+
+    componentDidUpdate(prevProps: Readonly<PinCodeProps>, prevState: Readonly<PinCodeState>, snapshot?: any) {
+
+        if (prevProps.value !== this.props.value) {
+            this.foundation.updateValueList(this.props.value.split(""));
+        }
+    }
+
+    get adapter(): PinCodeAdapter<PinCodeProps, PinCodeState> {
+        return {
+            ...super.adapter,
+            onCurrentActiveIndexChange: async (i) => {
+                await this.setStateAsync({ currentActiveIndex: i });
+            },
+            notifyValueChange: (values: string[]) => {
+                this.props.onChange?.(values.join(""));
+            },
+
+            changeSpecificInputFocusState: (index, state) => {
+                if (state === "focus") {
+                    this.inputDOMList[index]?.focus?.();
+                } else if (state === "blur") {
+                    this.inputDOMList[index]?.blur?.();
+                }
+            },
+            updateValueList: async (valueList: PinCodeState['valueList']) => {
+                await this.setStateAsync({ valueList });
+            }
+        };
+    }
+
+
+    focus = (index: number) => {
+        const inputDOM = this.inputDOMList[index];
+        inputDOM?.focus();
+        inputDOM?.setSelectionRange(1, 1);
+    }
+
+    blur = (index: number) => {
+        this.inputDOMList[index]?.blur();
+    }
+
+
+    renderSingleInput = (index: number) => {
+        return <Input
+            ref={dom => this.inputDOMList[index] = dom}
+            key={`input-${index}`}
+            autoFocus={this.props.autoFocus && index === 0}
+            value={this.state.valueList[index]}
+            size={this.props.size}
+            disabled={this.props.disabled}
+            onBlur={()=>this.foundation.handleCurrentActiveIndexChange(index, "blur")}
+            onFocus={()=>this.foundation.handleCurrentActiveIndexChange(index, "focus")}
+            onPaste={e=>this.foundation.handlePaste(e.nativeEvent, index)}
+            onKeyDown={e=>{
+                this.foundation.handleKeyDownOnSingleInput(e.nativeEvent, index);
+            }}
+            onChange={v => {
+                const userInputChar = v[v.length - 1];
+                if (this.foundation.validateValue(userInputChar)) {
+                    this.foundation.completeSingleInput(index, userInputChar);
+                }
+            }}/>;
+    }
+
+
+    render() {
+        const inputElements: ReactElement[] = [];
+        for (let i = 0; i < this.props.count; i++) {
+            inputElements.push(this.renderSingleInput(i));
+        }
+        return <div className={cls(`${cssClasses.PREFIX}-wrapper`, this.props.className)} style={this.props.style}>
+            {inputElements}
+        </div>;
+    }
+
+
+}
+
+export default PinCode;

+ 0 - 1
packages/semi-ui/tsconfig.json

@@ -16,7 +16,6 @@
             "@douyinfe/semi-theme-default/*": ["../semi-theme-default/*"],
             "@douyinfe/semi-theme-default/*": ["../semi-theme-default/*"],
             "@douyinfe/semi-icons": ["../semi-icons/src"]
             "@douyinfe/semi-icons": ["../semi-icons/src"]
         },
         },
-        "suppressImplicitAnyIndexErrors": true,
         "forceConsistentCasingInFileNames": true,
         "forceConsistentCasingInFileNames": true,
         "allowSyntheticDefaultImports": true,
         "allowSyntheticDefaultImports": true,
         "experimentalDecorators": true,
         "experimentalDecorators": true,

+ 0 - 1
tsconfig.json

@@ -17,7 +17,6 @@
             "@douyinfe/semi-icons": ["./packages/semi-icons/src/index"],
             "@douyinfe/semi-icons": ["./packages/semi-icons/src/index"],
             "@douyinfe/semi-illustrations": ["./packages/semi-illustrations/src/index"],
             "@douyinfe/semi-illustrations": ["./packages/semi-illustrations/src/index"],
         },
         },
-        "suppressImplicitAnyIndexErrors": true,
         "forceConsistentCasingInFileNames": true,
         "forceConsistentCasingInFileNames": true,
         "importHelpers": true,
         "importHelpers": true,
         "allowSyntheticDefaultImports": true,
         "allowSyntheticDefaultImports": true,