From 3675d2582dd6c4b552a048972795973e6e85eebc Mon Sep 17 00:00:00 2001 From: unix Date: Tue, 28 Apr 2020 11:19:39 +0800 Subject: [PATCH 1/3] feat(input): add password --- components/input/index.ts | 2 + components/input/input-icon.tsx | 11 ++-- components/input/input-props.ts | 40 +++++++++++++ components/input/input.tsx | 90 ++++++++++++------------------ components/input/password-icon.tsx | 27 +++++++++ components/input/password.tsx | 51 +++++++++++++++++ 6 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 components/input/input-props.ts create mode 100644 components/input/password-icon.tsx create mode 100644 components/input/password.tsx diff --git a/components/input/index.ts b/components/input/index.ts index 664e684..c801532 100644 --- a/components/input/index.ts +++ b/components/input/index.ts @@ -1,6 +1,8 @@ import Input from './input' import Textarea from '../textarea' +import InputPassword from './password' Input.Textarea = Textarea +Input.Password = InputPassword export default Input diff --git a/components/input/input-icon.tsx b/components/input/input-icon.tsx index 2a1f38f..35483bd 100644 --- a/components/input/input-icon.tsx +++ b/components/input/input-icon.tsx @@ -4,10 +4,12 @@ import useTheme from '../styles/use-theme' export interface InputIconProps { icon: React.ReactNode ratio: string + clickable: boolean + onClick: (e: React.MouseEvent) => void } const InputIcon: React.FC = React.memo(({ - icon, ratio, + icon, ratio, clickable, onClick, }) => { const theme = useTheme() const width = useMemo(() => { @@ -18,21 +20,22 @@ const InputIcon: React.FC = React.memo(({ }, [theme.layout.gap, ratio]) return ( - + {icon} diff --git a/components/input/input-props.ts b/components/input/input-props.ts new file mode 100644 index 0000000..0af90bf --- /dev/null +++ b/components/input/input-props.ts @@ -0,0 +1,40 @@ +import React from 'react' +import { NormalSizes, NormalTypes } from 'components/utils/prop-types' + +export interface Props { + value?: string + initialValue?: string + placeholder?: string + size?: NormalSizes + status?: NormalTypes + readOnly?: boolean + disabled?: boolean + label?: string + labelRight?: string + icon?: React.ReactNode + iconRight?: React.ReactNode + iconClickable?: boolean + width?: string + className?: string + clearable?: boolean + onChange?: (e: React.ChangeEvent) => void + onClearClick?: (e: React.MouseEvent) => void + onFocus?: (e: React.FocusEvent) => void + onBlur?: (e: React.FocusEvent) => void + onIconClick?: (e: React.MouseEvent) => void + autoComplete: string +} + +export const defaultProps = { + disabled: false, + readOnly: false, + clearable: false, + iconClickable: false, + width: 'initial', + size: 'medium' as NormalSizes, + status: 'default' as NormalTypes, + autoComplete: 'off', + className: '', + placeholder: '', + initialValue: '', +} diff --git a/components/input/input.tsx b/components/input/input.tsx index 378cb48..5be0f7f 100644 --- a/components/input/input.tsx +++ b/components/input/input.tsx @@ -1,47 +1,13 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import useTheme from '../styles/use-theme' import InputLabel from './input-label' import InputBlockLabel from './input-block-label' import InputIcon from './input-icon' import InputClearIcon from './input-icon-clear' import Textarea from '../textarea/textarea' +import InputPassword from './password' import { getSizes, getColors } from './styles' -import { NormalSizes, NormalTypes } from '../utils/prop-types' - -interface Props { - value?: string - initialValue?: string - placeholder?: string - size?: NormalSizes - status?: NormalTypes - readOnly?: boolean - disabled?: boolean - label?: string - labelRight?: string - icon?: React.ReactNode - iconRight?: React.ReactNode - width?: string - className?: string - clearable?: boolean - onChange?: (e: React.ChangeEvent) => void - onClearClick?: (e: React.MouseEvent) => void - onFocus?: (e: React.FocusEvent) => void - onBlur?: (e: React.FocusEvent) => void - autoComplete: string -} - -const defaultProps = { - disabled: false, - readOnly: false, - clearable: false, - width: 'initial', - size: 'medium' as NormalSizes, - status: 'default' as NormalTypes, - autoComplete: 'off', - className: '', - placeholder: '', - initialValue: '', -} +import { Props, defaultProps } from './input-props' type NativeAttrs = Omit, keyof Props> export type InputProps = Props & typeof defaultProps & NativeAttrs @@ -57,14 +23,16 @@ const simulateChangeEvent = ( } } -const Input: React.FC> = ({ - placeholder, label, labelRight, size, status, disabled, - icon, iconRight, initialValue, onChange, readOnly, value, - onClearClick, clearable, width, className, onBlur, onFocus, - autoComplete, children, ...props -}) => { - const ref = useRef(null) +const Input = React.forwardRef>(({ + label, labelRight, size, status, icon, iconRight, iconClickable, onIconClick, + initialValue, onChange, readOnly, value, onClearClick, clearable, width, + className, onBlur, onFocus, autoComplete, placeholder, children, disabled, + ...props +}, ref: React.Ref) => { const theme = useTheme() + const inputRef = useRef(null) + useImperativeHandle(ref, () => inputRef.current) + const [selfValue, setSelfValue] = useState(initialValue) const [hover, setHover] = useState(false) const { heightRatio, fontSize } = useMemo(() => getSizes(size),[size]) @@ -86,17 +54,18 @@ const Input: React.FC> = ({ setSelfValue(event.target.value) onChange && onChange(event) } - + const clearHandler = (event: React.MouseEvent) => { setSelfValue('') onClearClick && onClearClick(event) - if (!ref.current) return + if (!inputRef.current) return - const changeEvent = simulateChangeEvent(ref.current, event) + const changeEvent = simulateChangeEvent(inputRef.current, event) changeEvent.target.value = '' onChange && onChange(changeEvent) + inputRef.current.focus() } - + const focusHandler = (e: React.FocusEvent) => { setHover(true) onFocus && onFocus(e) @@ -105,20 +74,30 @@ const Input: React.FC> = ({ setHover(false) onBlur && onBlur(e) } - + + const iconClickHandler = (e: React.MouseEvent) => { + if (disabled) return + onIconClick && onIconClick(e) + } + const iconProps = useMemo(() => ({ + ratio: heightRatio, + clickable: iconClickable, + onClick: iconClickHandler, + }), [heightRatio, iconClickable]) + useEffect(() => { if (value === undefined) return setSelfValue(value) }, [value]) - + return (
{children && {children}}
{label && {label}}
- {icon && } - } + > = ({ heightRatio={heightRatio} disabled={disabled || readOnly} onClick={clearHandler} />} - {iconRight && } + {iconRight && }
{labelRight && {labelRight}}
@@ -225,10 +204,11 @@ const Input: React.FC> = ({ `}
) -} +}) -type InputComponent

= React.FC

& { +type InputComponent

= React.ForwardRefExoticComponent

& { Textarea: typeof Textarea + Password: typeof InputPassword } type ComponentProps = Partial & Omit & NativeAttrs diff --git a/components/input/password-icon.tsx b/components/input/password-icon.tsx new file mode 100644 index 0000000..4ec8c3e --- /dev/null +++ b/components/input/password-icon.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +interface Props { + visible: boolean +} + +const PasswordIcon: React.FC = ({ visible }) => { + return ( + + {!visible ? ( + <> + + + + ) : ( + <> + + + + )} + + ) +} + +export default PasswordIcon diff --git a/components/input/password.tsx b/components/input/password.tsx new file mode 100644 index 0000000..52f3bd0 --- /dev/null +++ b/components/input/password.tsx @@ -0,0 +1,51 @@ +import React, { useImperativeHandle, useMemo, useRef, useState } from 'react' +import withDefaults from '../utils/with-defaults' +import { Props, defaultProps } from './input-props' +import PasswordIcon from './password-icon' +import Input from './input' + +interface PasswordProps extends Props { + hideToggle?: boolean +} + +const passwordDefaultProps = { + ...defaultProps, + hideToggle: false, +} + +type NativeAttrs = Omit, keyof PasswordProps> +export type InputPasswordProps = PasswordProps & typeof passwordDefaultProps & NativeAttrs + +const InputPassword = React.forwardRef>(({ + hideToggle, children, ...props +}, ref: React.Ref) => { + const inputRef = useRef(null) + const [visible, setVisible] = useState(false) + useImperativeHandle(ref, () => inputRef.current) + + const iconClickHandler = () => { + if (hideToggle) return + setVisible(v => !v) + if (inputRef && inputRef.current) { + inputRef.current.focus() + } + } + + const inputProps = useMemo(() => ({ + ...props, + ref: inputRef, + iconClickable: true, + onIconClick: iconClickHandler, + type: visible ? 'text' : 'password', + }), [props, iconClickHandler, visible, inputRef]) + const icon = useMemo(() => { + if (hideToggle) return null + return + }, [hideToggle, visible]) + + return ( + {children} + ) +}) + +export default withDefaults(InputPassword, passwordDefaultProps) From 98a056305ccdcebc1a1589c995d9bc90fc0a4965 Mon Sep 17 00:00:00 2001 From: unix Date: Tue, 28 Apr 2020 11:19:56 +0800 Subject: [PATCH 2/3] docs(input): add docs for password --- pages/en-us/components/input.mdx | 43 +++++++++++++++++++++++++++++--- pages/zh-cn/components/input.mdx | 42 ++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/pages/en-us/components/input.mdx b/pages/en-us/components/input.mdx index 7fbee60..9cd494b 100644 --- a/pages/en-us/components/input.mdx +++ b/pages/en-us/components/input.mdx @@ -118,10 +118,17 @@ Retrieve text input from a user. scope={{ Input }} code={` <> - + `} /> + +`} /> + void` | - | - | | **onChange** | change event | `(e: React.ChangeEvent) => void` | - | - | | **onClearClick** | clear icon event | `(e: React.MouseEvent) => void` | - | - | | ... | native props | `InputHTMLAttributes` | `'alt', 'type', 'className', ...` | - | +Input.Password.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **hideToggle** | hide toggle icon | `boolean` | - | `false` | +| ... | input props | `Input.Props` | [Input.Props](#input.props) | - | + +InputSizes + +```ts +type InputSizes = 'mini' + | 'small' + | 'medium' + | 'large' +``` + +InputStatus + +```ts +type InputStatus = 'default' + | 'secondary' + | 'success' + | 'warning' + | 'error' +``` + useInput ```ts diff --git a/pages/zh-cn/components/input.mdx b/pages/zh-cn/components/input.mdx index 6a23487..88c2b16 100644 --- a/pages/zh-cn/components/input.mdx +++ b/pages/zh-cn/components/input.mdx @@ -112,13 +112,21 @@ export const meta = { `} /> + +`} /> + - + `} /> @@ -169,8 +177,8 @@ export const meta = { | **value** | 命令式设定输入框的值 | `string` | - | - | | **initialValue** | 初始值 | `string` | - | - | | **placeholder** | 占位文本 | `string` | - | - | -| **size** | 输入框大小 | `NormalSizes` | `'mini', 'small', 'medium', 'large'` | `medium` | -| **status** | 输入框状态 | `NormalTypes` | `'default', 'secondary', 'success', 'warning', 'error'` | `default` | +| **size** | 输入框大小 | `NormalSizes` | [InputSizes](#inputsizes) | `medium` | +| **status** | 输入框状态 | `NormalTypes` | [InputStatus](#inputstatus) | `default` | | **readOnly** | 是否设置输入框为只读 | `boolean` | - | `false` | | **disabled** | 是否禁用输入框 | `boolean` | - | `false` | | **clearable** | 是否展示清除按钮 | `boolean` | - | `false` | @@ -178,10 +186,38 @@ export const meta = { | **icon** | 输入框图标 | `React.ReactNode` | - | - | | **labelRight** | 居于右侧的文本标签 | `string` | - | - | | **iconRight** | 居于右侧的图标 | `React.ReactNode` | - | - | +| **iconClickable** | 图标是否可点击 | `boolean` | - | `false` | +| **onIconClick** | 图标点击事件 | `(e: React.ChangeEvent) => void` | - | - | | **onChange** | 输入框变化事件 | `(e: React.ChangeEvent) => void` | - | - | | **onClearClick** | 清除按钮的点击事件 | `(e: React.MouseEvent) => void` | - | - | | ... | 原生属性 | `InputHTMLAttributes` | `'alt', 'type', 'className', ...` | - | +Input.Password.Props + +| 属性 | 描述 | 类型 | 推荐值 | 默认 +| ---------- | ---------- | ---- | -------------- | ------ | +| **hideToggle** | 隐藏切换密码的按钮 | `boolean` | - | `false` | +| ... | 输入框组件属性 | `Input.Props` | [Input.Props](#input.props) | - | + +InputSizes + +```ts +type InputSizes = 'mini' + | 'small' + | 'medium' + | 'large' +``` + +InputStatus + +```ts +type InputStatus = 'default' + | 'secondary' + | 'success' + | 'warning' + | 'error' +``` + useInput ```ts From 574b3defc603a0b23f8ecd4fd6078874ad299d14 Mon Sep 17 00:00:00 2001 From: unix Date: Tue, 28 Apr 2020 11:31:52 +0800 Subject: [PATCH 3/3] test(input): add testcase for password --- .../__snapshots__/index.test.tsx.snap | 10 +- .../__snapshots__/password.test.tsx.snap | 103 ++++++++++++++++++ components/input/__tests__/index.test.tsx | 18 +++ components/input/__tests__/password.test.tsx | 26 +++++ components/input/input.tsx | 1 + components/input/password.tsx | 2 +- 6 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 components/input/__tests__/__snapshots__/password.test.tsx.snap create mode 100644 components/input/__tests__/password.test.tsx diff --git a/components/input/__tests__/__snapshots__/index.test.tsx.snap b/components/input/__tests__/__snapshots__/index.test.tsx.snap index 31c9a2b..fdb7af2 100644 --- a/components/input/__tests__/__snapshots__/index.test.tsx.snap +++ b/components/input/__tests__/__snapshots__/index.test.tsx.snap @@ -6,14 +6,15 @@ exports[`Input should be work with icon 1`] = ` box-sizing: content-box; display: flex; width: calc(1.687 * 16pt * .42); - height: calc(1.687 * 16pt * .42); + height: 100%; align-items: center; vertical-align: center; - pointer-events: none; margin: 0; padding: 0 calc(1.687 * 16pt * .3); line-height: 1; position: relative; + cursor: default; + pointer-events: none; } " +`; diff --git a/components/input/__tests__/index.test.tsx b/components/input/__tests__/index.test.tsx index 5220bf7..81688bb 100644 --- a/components/input/__tests__/index.test.tsx +++ b/components/input/__tests__/index.test.tsx @@ -122,4 +122,22 @@ describe('Input', () => { input.simulate('blur') expect(blur).toHaveBeenCalled() }) + + it('should trigger icon event', () => { + const click = jest.fn() + const wrapper = mount( + icon} onIconClick={click} iconClickable /> + ) + wrapper.find('#test-icon').simulate('click', nativeEvent) + expect(click).toHaveBeenCalled() + }) + + it('should ignore icon event when input disabled', () => { + const click = jest.fn() + const wrapper = mount( + icon} onIconClick={click} iconClickable disabled /> + ) + wrapper.find('#test-icon').simulate('click', nativeEvent) + expect(click).not.toHaveBeenCalled() + }) }) diff --git a/components/input/__tests__/password.test.tsx b/components/input/__tests__/password.test.tsx new file mode 100644 index 0000000..4c92d6d --- /dev/null +++ b/components/input/__tests__/password.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { mount } from 'enzyme' +import { Input } from 'components' +import { nativeEvent } from 'tests/utils' + +describe('InputPassword', () => { + it('should render correctly', () => { + const wrapper = mount() + const el = wrapper.find('input').getDOMNode() as HTMLInputElement + expect(el.type).toEqual('password') + expect(wrapper.html()).toMatchSnapshot() + expect(() => wrapper.unmount()).not.toThrow() + }) + + it('should toggle input type', () => { + const wrapper = mount() + wrapper.find('.input-icon').simulate('click', nativeEvent) + const el = wrapper.find('input').getDOMNode() as HTMLInputElement + expect(el.type).toEqual('text') + }) + + it('should hide toggle icon', () => { + const wrapper = mount() + expect(wrapper.find('.input-icon').length).toBe(0) + }) +}) diff --git a/components/input/input.tsx b/components/input/input.tsx index 5be0f7f..95b3d5b 100644 --- a/components/input/input.tsx +++ b/components/input/input.tsx @@ -58,6 +58,7 @@ const Input = React.forwardRef) => { setSelfValue('') onClearClick && onClearClick(event) + /* istanbul ignore next */ if (!inputRef.current) return const changeEvent = simulateChangeEvent(inputRef.current, event) diff --git a/components/input/password.tsx b/components/input/password.tsx index 52f3bd0..e6ff7d5 100644 --- a/components/input/password.tsx +++ b/components/input/password.tsx @@ -24,8 +24,8 @@ const InputPassword = React.forwardRef inputRef.current) const iconClickHandler = () => { - if (hideToggle) return setVisible(v => !v) + /* istanbul ignore next */ if (inputRef && inputRef.current) { inputRef.current.focus() }