import React, { forwardRef, useContext, useImperativeHandle, useRef, useState, useLayoutEffect, } from "react" import PropTypes from "prop-types" import classNames from "classnames" import Cleave from "cleave.js/react" import warning from "../_util/warning" import { ConfigContext, getComputedSize } from "../config-provider" import Textarea from "./Textarea" import "./style" const prefix = "adui-input" export interface IInputProps { [key: string]: any /** * input 标签原生属性 - autoComplete */ autoComplete?: "on" | "off" /** * input 标签原生属性 - autoFocus */ autoFocus?: boolean /** * 附加类名 */ className?: string /** * 实现手机号、日期、数字及自定义格式,可查看[示例](http://wxad.design/adui/components/input)。 */ cleaveOptions?: object /** * 宽度是否与内容宽度对齐 */ contentJustify?: boolean /** * 是否按照一个汉字算 2 字数的方式限制长度 */ countHanzi?: boolean /** * 默认值 - 内部驱动 */ defaultValue?: string | null /** * 是否禁用 */ disabled?: boolean /** * 作用到 input 元素的类名 */ inputClassName?: string /** * 作用到 input 元素的样式 */ inputStyle?: React.CSSProperties /** * 输入框的 id */ id?: string | null /** * 类型 */ intent?: "normal" | "primary" | "success" | "warning" | "danger" /** * 前置元素 */ leftElement?: JSX.Element /** * 限制长度 */ limit?: number | null /** * input 标签原生属性 - name */ name?: string | null /** * blur 时的 handler */ onBlur?: (e: React.FocusEvent) => void /** * 值改变时的 handler */ onChange?: (e: React.ChangeEvent, value?: string) => void /** * focus 时的 handler */ onFocus?: (e: React.FocusEvent) => void /** * keydown 时的 handler */ onKeyDown?: (e: React.KeyboardEvent) => void /** * enter 时的 handler */ onPressEnter?: (e: React.KeyboardEvent) => void /** * placeholder */ placeholder?: string /** * input 标签原生属性 - required */ required?: boolean /** * 后置元素 */ rightElement?: JSX.Element /** * 设置尺寸 */ size?: "mini" | "small" | "medium" | "large" /** * 附加样式 */ style?: React.CSSProperties /** * 设置主题 */ theme?: null | "light" /** * input 标签原生属性 - type */ type?: string | null /** * 值 - 外部控制 */ value?: string | null } export interface IInputRef { cleave: any input: HTMLInputElement leftElement: HTMLDivElement | null rightElement: HTMLDivElement | null updateElementsWidth: () => void handleChange: (e: React.ChangeEvent) => void handleKeyDown: (e: React.KeyboardEvent) => void handleBlur: (e: React.FocusEvent) => void handleFocus: (e: React.FocusEvent) => void handleAttachedElementClick: (position: "left" | "right") => void focus: () => void blur: () => void } export interface IInput extends React.ForwardRefExoticComponent< IInputProps & React.RefAttributes > { Textarea: typeof Textarea } /** * 输入框用于文本输入的区域,可结合图标、按钮、选择器等组件使用。 */ // @ts-ignore const Input: IInput = forwardRef( ( { autoComplete, autoFocus, className, cleaveOptions, contentJustify, countHanzi, defaultValue, disabled, id, inputClassName, inputStyle, intent, leftElement, limit, name, onBlur, onChange, onFocus, onKeyDown, onPressEnter, placeholder, required, rightElement, size: sizeProp, style, theme, type, value, ...otherProps }: IInputProps, ref ) => { const [leftElementWidth, setLeftElementWidth] = useState( null ) const [rightElementWidth, setRightElementWidth] = useState( null ) const inputRef = useRef(null) const wrapperElementRef = useRef(null) const leftElementRef = useRef(null) const rightElementRef = useRef(null) const justifyElementRef = useRef(null) const cleaveRef = useRef(null) const inputElement = inputRef?.current || cleaveRef?.current?.element const { size: sizeContext } = useContext(ConfigContext) const size = getComputedSize(sizeProp, sizeContext) const syncCleave = () => { /** * 修复 cleave.1.4.7 bug: */ if (cleaveRef?.current) { const { element, state, properties } = cleaveRef.current let { lastInputValue } = cleaveRef.current if (lastInputValue !== value) { lastInputValue = value } if (state.value !== value) { state.value = value } if (properties.result !== value) { properties.result = value } if (element && element.value !== value && value) { // 保存光标位置等后续恢复 const pos = element.selectionStart element.value = value // 简单等待一个microtask,否则无法设置光标位置 // eslint-disable-next-line max-len // see: https://github.com/zloirock/core-js/blob/master/packages/core-js/internals/microtask.js#LL46C61-L46C61 Promise.resolve().then(() => { element.setSelectionRange(pos, pos) }) } } } const handleChange = (e: React.ChangeEvent) => { if (!disabled && onChange) { /* eslint-disable no-control-regex, max-len */ // 08 Backspace, 09 Tab, 0A 换行符, 0B 垂直制表符, 0C 换页, 0D 回车, A0 不间断空格, // 2028/2029 分隔符, FEff 字节顺序标记。 const val = e.target.value.replace( /\u0008|\u0009|\u000A|\u000B|\u000C|\u000D|\u00A0|\u2028|\u2029|\uFEFF/g, "" ) onChange(e, val) } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && onPressEnter) { onPressEnter(e) } if (onKeyDown) { onKeyDown(e) } } const handleBlur = (e: React.FocusEvent) => { syncCleave() if (onBlur) { onBlur(e) } } const handleFocus = (e: React.FocusEvent) => { if (onFocus) { onFocus(e) } } const handleAttachedElementClick = (position: "left" | "right") => { if ( position === "left" && leftElement && leftElement.type && leftElement.type.name === "Icon" ) { inputElement.focus() inputElement.select() } if ( position === "right" && rightElement && rightElement.type && rightElement.type.name === "Icon" ) { inputElement.focus() inputElement.select() } } const fixControlledValue = (val?: string | null) => { if (typeof val === "undefined" || val === null) { return "" } return val } const updateElementsWidth = async () => { /** * 这个方法会在 didUpdate 和 didMount 时去 setState * 一定要避免造成循环。 */ await new Promise((resolve) => setTimeout(resolve, 0)) if (leftElementRef?.current) { const { clientWidth } = leftElementRef.current // 防止循环 if (Math.abs(clientWidth - (leftElementWidth || 0)) > 2) { setLeftElementWidth(clientWidth) } } else if (leftElementWidth) { setLeftElementWidth(null) } if (rightElementRef?.current) { const { clientWidth } = rightElementRef.current // 防止循环 if (Math.abs(clientWidth - (rightElementWidth || 0)) > 2) { setRightElementWidth(clientWidth) } } else if (rightElementWidth) { setRightElementWidth(null) } if (justifyElementRef?.current) { const { scrollWidth } = justifyElementRef.current if ( wrapperElementRef.current && Math.ceil(scrollWidth) !== wrapperElementRef.current.clientWidth ) { wrapperElementRef.current.style.width = `${Math.ceil(scrollWidth)}px` } } } const getFormatStrLeng = (str: string) => { const len = str.length let realLength = 0 let charCode = -1 for (let i = 0; i < len; i += 1) { charCode = str.charCodeAt(i) if (charCode >= 0 && charCode <= 128) { realLength += 0.5 } else { realLength += 1 } } return Math.ceil(realLength) } useLayoutEffect(() => { updateElementsWidth() syncCleave() }) useImperativeHandle(ref, () => { const realRef = inputRef?.current || cleaveRef?.current?.element return { cleave: cleaveRef?.current, input: realRef, leftElement: leftElementRef?.current, rightElement: rightElementRef?.current, updateElementsWidth, handleChange, handleKeyDown, handleBlur, handleFocus, handleAttachedElementClick, focus: () => realRef.focus(), blur: () => realRef.blur(), } }) const inputProps: { [key: string]: any } = { autoComplete, autoFocus, className: classNames(`${prefix}-base`, inputClassName), disabled, id, name, onBlur: handleBlur, onChange: handleChange, onFocus: handleFocus, onKeyDown: handleKeyDown, placeholder, ref: inputRef, required, style: { paddingLeft: leftElementWidth ? `${leftElementWidth}px` : undefined, paddingRight: rightElementWidth ? `${rightElementWidth}px` : undefined, ...inputStyle, }, type, } const cleaveInputProps = { ...inputProps } if (value !== null) { inputProps.value = fixControlledValue(value) } else { inputProps.defaultValue = fixControlledValue(defaultValue) } /** * cleave 不接收原生的 defaultValue,必须传入 value prop 来实现 defaultValue 的作用 * https://github.com/nosir/cleave.js/blob/master/doc/reactjs-component-usage.md#how-to-pass-default-value */ cleaveInputProps.value = value || defaultValue || "" const input = !cleaveOptions || Object.keys(cleaveOptions).length === 0 ? ( ) : ( ) let length if (limit) { warning( !rightElement, "Input", "不允许同时使用 prop `limit` 与 prop `rightElement`" ) if (value) { length = countHanzi ? getFormatStrLeng(value) : value.length } else { warning( value !== null, "Input", "如果要使用 limit Prop,必须从外部控制 value。" ) length = 0 } } if (contentJustify) { warning( value !== null, "Input", "如果使用 contentJusfity Prop,必须从外部控制 value。" ) } const classSet = classNames( className, `${prefix}-wrapper`, `${prefix}-${size}`, { [`${prefix}-${theme}`]: theme, [`${prefix}-disabled`]: disabled, [`${prefix}-limited`]: limit, [`${prefix}-limited_digit-over-2`]: limit && limit.toString().length > 2, [`${prefix}-normal`]: intent === "normal", [`${prefix}-primary`]: intent === "primary", [`${prefix}-success`]: intent === "success", [`${prefix}-warning`]: intent === "warning", [`${prefix}-danger`]: intent === "danger" || (length && limit && length > limit), } ) const leftType = leftElement && leftElement.type ? leftElement.type.type : "" const rightType = rightElement && rightElement.type ? rightElement.type.type : "" return (
{!!leftElement && (
handleAttachedElementClick("left")} role="none" className={classNames(`${prefix}-left`, { [`${prefix}-left_button`]: leftType === "Button", [`${prefix}-left_icon`]: leftType === "Icon", [`${prefix}-left_select`]: leftType === "Select", })} > {leftElement}
)} {input} {!!rightElement && (
handleAttachedElementClick("right")} role="none" className={classNames(`${prefix}-right`, { [`${prefix}-right_button`]: rightType === "Button", [`${prefix}-right_icon`]: rightType === "Icon", [`${prefix}-right_select`]: rightType === "Select", })} > {rightElement}
)} {limit && (
{`${length}/${limit}`}
)} {contentJustify && (
{fixControlledValue(value)}
)}
) } ) Input.Textarea = Textarea Input.displayName = "Input" Input.propTypes = { /** * input 标签原生属性 - autoComplete */ autoComplete: PropTypes.oneOf(["on", "off"]), /** * input 标签原生属性 - autoFocus */ autoFocus: PropTypes.bool, /** * 附加类名 */ className: PropTypes.string, /** * 实现手机号、日期、数字及自定义格式,可查看[示例](http://wxad.design/adui/components/input)。 */ cleaveOptions: PropTypes.object, /** * 宽度是否与内容宽度对齐 */ contentJustify: PropTypes.bool, /** * 是否按照一个汉字算 2 字数的方式限制长度 */ countHanzi: PropTypes.bool, /** * 默认值 - 内部驱动 */ defaultValue: PropTypes.string, /** * 是否禁用 */ disabled: PropTypes.bool, /** * 输入框的 id */ id: PropTypes.string, /** * 作用到 input 元素的类名 */ inputClassName: PropTypes.string, /** * 作用到 input 元素的样式 */ inputStyle: PropTypes.object, /** * 类型 */ intent: PropTypes.oneOf([ "normal", "primary", "success", "warning", "danger", ]), /** * 前置元素 */ leftElement: PropTypes.any, /** * 限制长度 */ limit: PropTypes.number, /** * input 标签原生属性 - name */ name: PropTypes.string, /** * blur 时的 handler */ onBlur: PropTypes.func, /** * 值改变时的 handler */ onChange: PropTypes.func, /** * focus 时的 handler */ onFocus: PropTypes.func, /** * keydown 时的 handler */ onKeyDown: PropTypes.func, /** * enter 时的 handler */ onPressEnter: PropTypes.func, /** * placeholder */ placeholder: PropTypes.string, /** * input 标签原生属性 - required */ required: PropTypes.bool, /** * 后置元素 */ rightElement: PropTypes.any, /** * 设置尺寸 */ size: PropTypes.oneOf(["mini", "small", "medium", "large"]), /** * 附加样式 */ style: PropTypes.object, /** * 设置主题 */ theme: PropTypes.oneOf([null, "light"]), /** * input 标签原生属性 - type */ type: PropTypes.string, /** * 值 - 外部控制 */ value: PropTypes.string, } Input.defaultProps = { autoComplete: "off", autoFocus: false, className: undefined, cleaveOptions: {}, contentJustify: false, countHanzi: true, defaultValue: null, disabled: false, id: null, intent: "normal", inputClassName: undefined, inputStyle: {}, leftElement: undefined, limit: null, name: null, onBlur: () => {}, onChange: () => {}, onFocus: () => {}, onKeyDown: () => {}, onPressEnter: () => {}, placeholder: "请输入", required: false, rightElement: undefined, size: "small", style: {}, theme: null, type: null, value: null, } export default Input