diff --git a/packages/arco-vue-docs/components/aside-nav/style.less b/packages/arco-vue-docs/components/aside-nav/style.less index cf7f0a09f..cdea6ba98 100644 --- a/packages/arco-vue-docs/components/aside-nav/style.less +++ b/packages/arco-vue-docs/components/aside-nav/style.less @@ -16,13 +16,13 @@ position: absolute; top: 186px; right: -12px; + display: flex; + align-items: center; + justify-content: center; background-color: var(--color-bg-5); border: 1px solid var(--color-fill-3); box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.1); transition: all 0.15s; - display: flex; - justify-content: center; - align-items: center; &:hover { background-color: var(--color-bg-5); @@ -141,7 +141,10 @@ background-color: var(--color-fill-2) !important; .aside-nav-item-link { + overflow: hidden; color: rgb(var(--arcoblue-6)); + white-space: nowrap; + text-overflow: ellipsis; } } diff --git a/packages/arco-vue-docs/locale/en-us.js b/packages/arco-vue-docs/locale/en-us.js index 5df1ebb8e..3489462e1 100644 --- a/packages/arco-vue-docs/locale/en-us.js +++ b/packages/arco-vue-docs/locale/en-us.js @@ -93,6 +93,7 @@ export default { overflow: 'OverflowList', scrollbar: 'Scrollbar', watermark: 'Watermark', + verificationCode: 'VerificationCode', }, footer: { design: 'Design', diff --git a/packages/arco-vue-docs/locale/zh-cn.js b/packages/arco-vue-docs/locale/zh-cn.js index 580fc9d7a..9ff713667 100644 --- a/packages/arco-vue-docs/locale/zh-cn.js +++ b/packages/arco-vue-docs/locale/zh-cn.js @@ -93,6 +93,7 @@ export default { overflow: '折叠列表 OverflowList', scrollbar: '滚动条 Scrollbar', watermark: '水印 Watermark', + verificationCode: '验证码输入框 VerificationCode', }, footer: { design: '设计', diff --git a/packages/arco-vue-docs/router.ts b/packages/arco-vue-docs/router.ts index a85564625..71ae186e4 100644 --- a/packages/arco-vue-docs/router.ts +++ b/packages/arco-vue-docs/router.ts @@ -187,6 +187,10 @@ const ScrollbarEn = () => const Watermark = () => import('@web-vue/components/watermark/README.zh-CN.md'); const WatermarkEn = () => import('@web-vue/components/watermark/README.en-US.md'); +const VerificationCode = () => + import('@web-vue/components/verification-code/README.zh-CN.md'); +const VerificationCodeEn = () => + import('@web-vue/components/verification-code/README.en-US.md'); const docs = [ { @@ -475,6 +479,11 @@ const components = [ component: InputNumber, componentEn: InputNumberEn, }, + { + name: 'verificationCode', + component: VerificationCode, + componentEn: VerificationCodeEn, + }, { name: 'inputTag', component: InputTag, diff --git a/packages/web-vue/components/arco-vue.ts b/packages/web-vue/components/arco-vue.ts index 8552fb704..78935a4ff 100644 --- a/packages/web-vue/components/arco-vue.ts +++ b/packages/web-vue/components/arco-vue.ts @@ -96,6 +96,7 @@ import Typography, { } from './typography'; import Upload from './upload'; import OverflowList from './overflow-list'; +import VerificationCode from './verification-code'; import Watermark from './watermark'; import { useFormItem } from './_hooks/use-form-item'; @@ -173,6 +174,7 @@ const components: Record = { Icon, OverflowList, Watermark, + VerificationCode, }; const install = (app: App, options?: ArcoOptions) => { diff --git a/packages/web-vue/components/components.ts b/packages/web-vue/components/components.ts index 3878d552a..ff79473fd 100644 --- a/packages/web-vue/components/components.ts +++ b/packages/web-vue/components/components.ts @@ -128,6 +128,7 @@ declare module '@vue/runtime-core' { ATypographyTitle: typeof import('@arco-design/web-vue')['TypographyTitle']; ATypographyText: typeof import('@arco-design/web-vue')['TypographyText']; AUpload: typeof import('@arco-design/web-vue')['Upload']; + AVerificationCode: typeof import('@arco-design/web-vue')['VerificationCode']; AWatermark: typeof import('@arco-design/web-vue')['Watermark']; } diff --git a/packages/web-vue/components/index.less b/packages/web-vue/components/index.less index b78062a4d..e3c8f9ca7 100644 --- a/packages/web-vue/components/index.less +++ b/packages/web-vue/components/index.less @@ -72,3 +72,4 @@ @import './tree/style/index.less'; @import './typography/style/index.less'; @import './upload/style/index.less'; +@import './verification-code/style/index.less'; diff --git a/packages/web-vue/components/index.ts b/packages/web-vue/components/index.ts index 95dcde5f3..5acb1d379 100644 --- a/packages/web-vue/components/index.ts +++ b/packages/web-vue/components/index.ts @@ -332,6 +332,8 @@ export type { } from './upload'; export { default as OverflowList } from './overflow-list'; export type { OverflowListInstance } from './overflow-list'; +export { default as VerificationCode } from './verification-code'; +export type { VerificationCodeInstance } from './verification-code'; export { default as Watermark } from './watermark'; export type { WatermarkInstance } from './watermark'; // hooks diff --git a/packages/web-vue/components/verification-code/README.en-US.md b/packages/web-vue/components/verification-code/README.en-US.md new file mode 100644 index 000000000..ed96a85b0 --- /dev/null +++ b/packages/web-vue/components/verification-code/README.en-US.md @@ -0,0 +1,43 @@ +```yaml +meta: + type: Component + category: Data Entry +title: VerificationCode +description: Verification code input component. +``` + +*Auto translate by google.* + +@import ./__demo__/basic.md +@import ./__demo__/status.md +@import ./__demo__/masked.md +@import ./__demo__/separator.md +@import ./__demo__/form.md +@import ./__demo__/formatter.md + +## API + + +### `` Props + +|Attribute|Description|Type|Default| +|---|---|---|:---:| +|model-value **(v-model)**|Value|`string`|`-`| +|default-value|Default value (uncontrolled state)|`string`|`''`| +|length|The length of the verification code, rendering the corresponding number of input boxes according to the length.|`number`|`6`| +|size|Input size|`'mini' \| 'small' \| 'medium' \| 'large'`|`'medium'`| +|disabled|Whether to disable|`boolean`|`false`| +|masked|Password mode|`boolean`|`false`| +|readonly|Readonly|`boolean`|`false`| +|error|Whether it is an error state|`boolean`|`false`| +|separator|Separator. Customizable rendering separators after input boxes with different indexes|`(index: number, character: string) => VNode`|`-`| +|formatter|Formatter function, triggered when the user input value changes|`(inputValue: string, index: number, value: string) => string \| boolean`|`-`| +### `` Events + +|Event Name|Description|Parameters| +|---|---|---| +|change|Triggered when the value changes|value: ` string `| +|finish|Triggered when the filling is complete|value: ` string `| +|input|Triggered on input|inputValue: ` string `
index: ` number `
ev: `Event`| + + diff --git a/packages/web-vue/components/verification-code/README.zh-CN.md b/packages/web-vue/components/verification-code/README.zh-CN.md new file mode 100644 index 000000000..3c7d9cbb6 --- /dev/null +++ b/packages/web-vue/components/verification-code/README.zh-CN.md @@ -0,0 +1,41 @@ +```yaml +meta: + type: 组件 + category: 数据输入 +title: 验证码输入 VerificationCode +description: 验证码输入组件 +``` + +@import ./__demo__/basic.md +@import ./__demo__/status.md +@import ./__demo__/masked.md +@import ./__demo__/separator.md +@import ./__demo__/form.md +@import ./__demo__/formatter.md + +## API + + +### `` Props + +|参数名|描述|类型|默认值| +|---|---|---|:---:| +|model-value **(v-model)**|绑定值|`string`|`-`| +|default-value|默认值(非受控状态)|`string`|`''`| +|length|验证码的长度,根据长度渲染对应个数的输入框|`number`|`6`| +|size|输入框大小|`'mini' \| 'small' \| 'medium' \| 'large'`|`'medium'`| +|disabled|是否禁用|`boolean`|`false`| +|masked|是否密码模式|`boolean`|`false`| +|readonly|只读|`boolean`|`false`| +|error|是否为错误状态|`boolean`|`false`| +|separator|分隔符。可在不同索引的输入框后自定义渲染分隔符|`(index: number, character: string) => VNode`|`-`| +|formatter|格式化函数,当用户输入值改变时触发|`(inputValue: string, index: number, value: string) => string \| boolean`|`-`| +### `` Events + +|事件名|描述|参数| +|---|---|---| +|change|值发生改变时触发|value: ` string `| +|finish|填充完成时触发|value: ` string `| +|input|输入时触发|inputValue: ` string `
index: ` number `
ev: `Event`| + + diff --git a/packages/web-vue/components/verification-code/TEMPLATE.md b/packages/web-vue/components/verification-code/TEMPLATE.md new file mode 100644 index 000000000..2517e7c07 --- /dev/null +++ b/packages/web-vue/components/verification-code/TEMPLATE.md @@ -0,0 +1,29 @@ +## zh-CN +```yaml +meta: + type: 组件 + category: 数据输入 +title: 验证码输入 VerificationCode +description: 验证码输入组件 +``` +--- +## en-US +```yaml +meta: + type: Component + category: Data Entry +title: VerificationCode +description: Verification code input component. +``` +--- + +@import ./__demo__/basic.md +@import ./__demo__/status.md +@import ./__demo__/masked.md +@import ./__demo__/separator.md +@import ./__demo__/form.md +@import ./__demo__/formatter.md + +## API + +%%API(verification-code.tsx)%% diff --git a/packages/web-vue/components/verification-code/__demo__/basic.md b/packages/web-vue/components/verification-code/__demo__/basic.md new file mode 100644 index 000000000..449d618d5 --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/basic.md @@ -0,0 +1,31 @@ +```yaml +title: + zh-CN: 基本使用 + en-US: Basic Usage +``` + +## zh-CN + +验证码输入框的基本用法。 + +--- + +## en-US + +Basic usage + +--- + +```vue + + + +``` diff --git a/packages/web-vue/components/verification-code/__demo__/form.md b/packages/web-vue/components/verification-code/__demo__/form.md new file mode 100644 index 000000000..8ca075787 --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/form.md @@ -0,0 +1,50 @@ +```yaml +title: + zh-CN: 配合表单使用 + en-US: With Form +``` + +## zh-CN + +配合表单使用实现校验。 + +--- + +## en-US + +Use with forms to implement verification. + +--- + +```vue + + + +``` diff --git a/packages/web-vue/components/verification-code/__demo__/formatter.md b/packages/web-vue/components/verification-code/__demo__/formatter.md new file mode 100644 index 000000000..20b06dfa6 --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/formatter.md @@ -0,0 +1,34 @@ +```yaml +title: + zh-CN: 格式化输入 + en-US: Formatter input +``` + +## zh-CN + +通过 `formatter` 校验输入。此外,可以返回非布尔类型来将用户输入的字符串为特定的格式。 + +--- + +## en-US + +Validate input using `formatter`. Additionally, it can return non-boolean types to format the user-entered string into a specific format. + +--- + +```vue + +``` diff --git a/packages/web-vue/components/verification-code/__demo__/masked.md b/packages/web-vue/components/verification-code/__demo__/masked.md new file mode 100644 index 000000000..a35324bea --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/masked.md @@ -0,0 +1,29 @@ +```yaml +title: + zh-CN: 密码模式 + en-US: Masked +``` + +## zh-CN + +指定 `masked = true`可开启密码模式 + +--- + +## en-US + +Use `masked = true` to turn on password mode + +--- + +```vue + + + +``` diff --git a/packages/web-vue/components/verification-code/__demo__/separator.md b/packages/web-vue/components/verification-code/__demo__/separator.md new file mode 100644 index 000000000..d5f370d88 --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/separator.md @@ -0,0 +1,32 @@ +```yaml +title: + zh-CN: 自定义分隔符 + en-US: Custom separator +``` + +## zh-CN + +指定 `separator` 可以自定义渲染分隔符。 + +--- + +## en-US + +Specify `separator` to customize the rendering separator + +--- + +```vue + + + +``` diff --git a/packages/web-vue/components/verification-code/__demo__/status.md b/packages/web-vue/components/verification-code/__demo__/status.md new file mode 100644 index 000000000..8f7a42c1d --- /dev/null +++ b/packages/web-vue/components/verification-code/__demo__/status.md @@ -0,0 +1,36 @@ +```yaml +title: + zh-CN: 不同状态 + en-US: Different status +``` + +## zh-CN + +禁用状态、只读状态、错误状态。 + +--- + +## en-US + +Disabled, readonly, error status. + +--- + +```vue + +``` diff --git a/packages/web-vue/components/verification-code/__test__/__snapshots__/demo.test.ts.snap b/packages/web-vue/components/verification-code/__test__/__snapshots__/demo.test.ts.snap new file mode 100644 index 000000000..25042ea48 --- /dev/null +++ b/packages/web-vue/components/verification-code/__test__/__snapshots__/demo.test.ts.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` demo: render [basic] correctly 1`] = ` +"
+ + + + + + +
" +`; + +exports[` demo: render [form] correctly 1`] = ` +"
+
+
+
+
+
+
+ + + + + + +
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+
" +`; + +exports[` demo: render [formatter] correctly 1`] = ` +"
+ +
+
+ + + + + + +
+
+ +
+
+ + + + + + +
+
+
" +`; + +exports[` demo: render [masked] correctly 1`] = ` +"
+ + + + + + +
" +`; + +exports[` demo: render [separator] correctly 1`] = ` +"
+ + - + + - + + + +
" +`; + +exports[` demo: render [status] correctly 1`] = ` +"
+ +
+
+ +
Disabled:
+ +
+
+ + + + + + +
+
+
+
+ +
+
+ +
Readonly:
+ +
+
+ + + + + + +
+
+
+
+ +
+
+ +
Error:
+ +
+
+ + + + + + +
+
+
+
+
" +`; diff --git a/packages/web-vue/components/verification-code/__test__/demo.test.ts b/packages/web-vue/components/verification-code/__test__/demo.test.ts new file mode 100644 index 000000000..62cfe82c9 --- /dev/null +++ b/packages/web-vue/components/verification-code/__test__/demo.test.ts @@ -0,0 +1,3 @@ +import demoTest from '../../../scripts/demo-test'; + +demoTest('verification-code'); diff --git a/packages/web-vue/components/verification-code/index.ts b/packages/web-vue/components/verification-code/index.ts new file mode 100644 index 000000000..312423dff --- /dev/null +++ b/packages/web-vue/components/verification-code/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue'; +import type { ArcoOptions } from '../_utils/types'; +import { setGlobalConfig, getComponentPrefix } from '../_utils/global-config'; +import _VerificationCode from './verification-code'; + +const VerificationCode = Object.assign(_VerificationCode, { + install: (app: App, options?: ArcoOptions) => { + setGlobalConfig(app, options); + const componentPrefix = getComponentPrefix(options); + + app.component(componentPrefix + _VerificationCode.name, _VerificationCode); + }, +}); + +export type VerificationCodeInstance = InstanceType; + +export default VerificationCode; diff --git a/packages/web-vue/components/verification-code/style/index.less b/packages/web-vue/components/verification-code/style/index.less new file mode 100644 index 000000000..90325f2af --- /dev/null +++ b/packages/web-vue/components/verification-code/style/index.less @@ -0,0 +1,32 @@ +@import './token.less'; +@import '../../input/style/token.less'; + +@verification-code-prefix-cls: ~'@{prefix}-verification-code'; +@input-prefix-cls: ~'@{prefix}-input'; + +.@{verification-code-prefix-cls} { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + column-gap: 4px; + + .@{input-prefix-cls} { + width: @input-size-default-height; + padding-right: 0; + padding-left: 0; + text-align: center; + } + + .@{input-prefix-cls}-size-small { + width: @input-size-small-height; + } + + .@{input-prefix-cls}-size-mini { + width: @input-size-mini-height; + } + + .@{input-prefix-cls}-size-large { + width: @input-size-large-height; + } +} diff --git a/packages/web-vue/components/verification-code/style/index.ts b/packages/web-vue/components/verification-code/style/index.ts new file mode 100644 index 000000000..3a3ab0de5 --- /dev/null +++ b/packages/web-vue/components/verification-code/style/index.ts @@ -0,0 +1,2 @@ +import '../../style/index.less'; +import './index.less'; diff --git a/packages/web-vue/components/verification-code/style/token.less b/packages/web-vue/components/verification-code/style/token.less new file mode 100644 index 000000000..e76f46633 --- /dev/null +++ b/packages/web-vue/components/verification-code/style/token.less @@ -0,0 +1 @@ +@import '../../style/theme/index.less'; diff --git a/packages/web-vue/components/verification-code/verification-code.tsx b/packages/web-vue/components/verification-code/verification-code.tsx new file mode 100644 index 000000000..e8bb18cd1 --- /dev/null +++ b/packages/web-vue/components/verification-code/verification-code.tsx @@ -0,0 +1,228 @@ +import { PropType, VNode, computed, defineComponent, ref, watch } from 'vue'; +import { Size } from '../_utils/constant'; +import { getPrefixCls } from '../_utils/global-config'; +import ArcoInput from '../input'; +import { isExist, isFunction } from '../_utils/is'; +import { Backspace, ArrowLeft, ArrowRight } from '../_utils/keycode'; + +export default defineComponent({ + name: 'VerificationCode', + props: { + /** + * @zh 绑定值 + * @en Value + */ + modelValue: String, + /** + * @zh 默认值(非受控状态) + * @en Default value (uncontrolled state) + */ + defaultValue: { + type: String, + default: '', + }, + /** + * @zh 验证码的长度,根据长度渲染对应个数的输入框 + * @en The length of the verification code, rendering the corresponding number of input boxes according to the length. + */ + length: { + type: Number, + default: 6, + }, + /** + * @zh 输入框大小 + * @en Input size + * @values 'mini','small','medium','large' + * @defaultValue 'medium' + */ + size: { + type: String as PropType, + }, + /** + * @zh 是否禁用 + * @en Whether to disable + */ + disabled: Boolean, + /** + * @zh 是否密码模式 + * @en Password mode + */ + masked: Boolean, + /** + * @zh 只读 + * @en Readonly + */ + readonly: Boolean, + /** + * @zh 是否为错误状态 + * @en Whether it is an error state + */ + error: { + type: Boolean, + default: false, + }, + /** + * @zh 分隔符。可在不同索引的输入框后自定义渲染分隔符 + * @en Separator. Customizable rendering separators after input boxes with different indexes + */ + separator: { + type: Function as PropType<(index: number, character: string) => VNode>, + }, + /** + * @zh 格式化函数,当用户输入值改变时触发 + * @en Formatter function, triggered when the user input value changes + */ + formatter: { + type: Function as PropType< + (inputValue: string, index: number, value: string) => string | boolean + >, + }, + }, + emits: { + 'update:modelValue': (value: string) => true, + /** + * @zh 值发生改变时触发 + * @en Triggered when the value changes + * @param { string } value + */ + 'change': (value: string) => true, + /** + * @zh 填充完成时触发 + * @en Triggered when the filling is complete + * @param { string } value + */ + 'finish': (value: string) => true, + /** + * @zh 输入时触发 + * @en Triggered on input + * @param { string } inputValue + * @param { number } index + * @param {Event} ev + */ + 'input': (inputValue: string, index: number, ev: Event) => true, + }, + setup(props, { emit }) { + const prefixCls = getPrefixCls('verification-code'); + const prefixInputCls = getPrefixCls('input'); + const inputRefList = ref([] as HTMLElement[]); + + const mergedValue = computed(() => props.modelValue ?? props.defaultValue); + const type = computed(() => (props.masked ? 'password' : 'text')); + const inputCls = computed(() => [ + prefixInputCls, + { + [`${prefixInputCls}-size-${props.size}`]: props.size, + }, + ]); + + const filledValue = computed(() => { + const newVal = String(mergedValue.value).split(''); + return new Array(props.length).fill('').map((_, index) => { + return isExist(newVal[index]) ? String(newVal[index]) : ''; + }) as string[]; + }); + + const innerValue = ref(filledValue.value); + + watch(mergedValue, () => { + innerValue.value = filledValue.value; + }); + + const updateValue = () => { + const value = innerValue.value.join('').trim(); + emit('update:modelValue', value); + emit('change', value); + if (value.length === props.length) { + emit('finish', value); + } + }; + + const handleFocus = (index: number) => inputRefList?.value[index].focus(); + const focusFirstEmptyInput = (index?: number) => { + if (isExist(index) && innerValue.value[index as number]) { + return; + } + for (let i = 0; i < innerValue.value.length; i++) { + if (!innerValue.value[i]) { + handleFocus(i); + break; + } + } + }; + + const handlePaste = (e: ClipboardEvent, index: number) => { + e.preventDefault(); + const { clipboardData } = e; + const text = clipboardData?.getData('text'); + if (text) { + const pasteValues = text.split('').slice(0, props.length - index); + innerValue.value.splice(index, pasteValues.length, ...pasteValues); + updateValue(); + } + }; + + const handleKeydown = (index: number, e: KeyboardEvent) => { + const keyCode = e.code || e.key; + + if (keyCode === Backspace.code && !innerValue.value[index]) { + e.preventDefault(); + innerValue.value[Math.max(index - 1, 0)] = ''; + updateValue(); + focusFirstEmptyInput(); + } else if (keyCode === ArrowLeft.code && index > 0) { + e.preventDefault(); + handleFocus(index - 1); + } else if ( + keyCode === ArrowRight.code && + innerValue.value[index] && + index < props.length - 1 + ) { + e.preventDefault(); + handleFocus(index + 1); + } + }; + + const handleInput = (index: number, value: string, event: Event) => { + let char = (value || '').trim().charAt(value.length - 1); + emit('input', char, index, event); + + if (isFunction(props.formatter)) { + const result = props.formatter(char, index, innerValue.value.join('')); + if (result === false) return; + char = result as string; + } + + innerValue.value[index] = char; + updateValue(); + focusFirstEmptyInput(); + }; + + return () => { + return ( +
+ {innerValue.value.map((c, i) => ( + <> + (inputRefList.value[i] = el)} + type={type.value} + class={inputCls.value} + modelValue={c} + size={props.size} + error={props.error} + disabled={props.disabled} + readonly={props.readonly} + onFocus={() => focusFirstEmptyInput(i)} + onInput={(v, e) => handleInput(i, v, e)} + // @ts-ignore + onKeydown={(e) => handleKeydown(i, e)} + onPaste={(e: ClipboardEvent) => handlePaste(e, i)} + /> + {props.separator?.(i, c)} + + ))} +
+ ); + }; + }, +});