diff --git a/packages/ccui/ui/input/index.ts b/packages/ccui/ui/input/index.ts new file mode 100644 index 00000000..bf039e7e --- /dev/null +++ b/packages/ccui/ui/input/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Input from './src/input' + +Input.install = function (app: App): void { + app.component(Input.name, Input) +} + +export { Input } + +export default { + title: 'Input 输入框', + category: '数据录入', + status: '100%', + install(app: App): void { + app.component(Input.name, Input) + }, +} diff --git a/packages/ccui/ui/input/src/input-types.ts b/packages/ccui/ui/input/src/input-types.ts new file mode 100644 index 00000000..c87dd367 --- /dev/null +++ b/packages/ccui/ui/input/src/input-types.ts @@ -0,0 +1,49 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type InputType = 'text' | 'password' +export type InputSize = 'large' | 'default' | 'small' + +export const inputProps = { + type: { + type: String as PropType, + default: 'text', + }, + size: { + type: String as PropType, + default: 'default', + }, + placeholder: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + readonly: { + type: Boolean, + default: false, + }, + clearable: { + type: Boolean, + default: false, + }, + showPassword: { + type: Boolean, + default: false, + }, + prepend: { + type: String, + default: '', + }, + append: { + type: String, + default: '', + }, + modelValue: { + type: String, + default: '', + }, +} as const + +export type InputProps = ExtractPropTypes diff --git a/packages/ccui/ui/input/src/input.scss b/packages/ccui/ui/input/src/input.scss new file mode 100644 index 00000000..7cc78e53 --- /dev/null +++ b/packages/ccui/ui/input/src/input.scss @@ -0,0 +1,177 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-input { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + background-color: $ccui-base-bg; + border: 1px solid $ccui-line; + border-radius: $ccui-border-radius; + color: $ccui-text; + font-size: 14px; + transition: + border-color $ccui-animation-duration-base, + box-shadow $ccui-animation-duration-base; + + &:hover { + border-color: $ccui-brand-hover; + } + + &:focus-within { + border-color: $ccui-brand; + box-shadow: 0 0 0 2px rgba($ccui-brand, 0.2); + } + + &--disabled { + background-color: $ccui-disabled-bg; + border-color: $ccui-disabled-line; + color: $ccui-disabled-text; + cursor: not-allowed; + + &:hover { + border-color: $ccui-disabled-line; + } + } + + &--large { + height: $ccui-size-lg; + font-size: $ccui-font-size-lg; + + &.#{$cls-prefix}-input--prefix { + .#{$cls-prefix}-input__inner { + padding-left: 24px; + } + } + + &.#{$cls-prefix}-input--suffix, + &.#{$cls-prefix}-input--clearable { + .#{$cls-prefix}-input__inner { + padding-right: 24px; + } + } + } + + &--default { + height: $ccui-size-md; + font-size: $ccui-font-size-md; + + &.#{$cls-prefix}-input--prefix { + .#{$cls-prefix}-input__inner { + padding-left: 24px; + } + } + + &.#{$cls-prefix}-input--suffix, + &.#{$cls-prefix}-input--clearable { + .#{$cls-prefix}-input__inner { + padding-right: 24px; + } + } + } + + &--small { + height: $ccui-size-sm; + font-size: $ccui-font-size-sm; + + &.#{$cls-prefix}-input--prefix { + .#{$cls-prefix}-input__inner { + padding-left: 20px; + } + } + + &.#{$cls-prefix}-input--suffix, + &.#{$cls-prefix}-input--clearable { + .#{$cls-prefix}-input__inner { + padding-right: 20px; + } + } + } + + &__wrapper { + position: relative; + width: 100%; + padding: 0 8px; + } + + &__prepend, + &__append { + display: flex; + align-items: center; + white-space: nowrap; + background-color: $ccui-area; + color: $ccui-text; + font-size: inherit; + line-height: 1; + height: 100%; + padding: 0 10px; + } + + &__prepend { + border-radius: $ccui-border-radius 0 0 $ccui-border-radius; + border-right: 1px solid $ccui-line; + } + + &__append { + border-radius: 0 $ccui-border-radius $ccui-border-radius 0; + border-left: 1px solid $ccui-line; + } + + &__inner { + width: 100%; + height: 100%; + background: transparent; + border: none; + outline: none; + color: inherit; + font-size: inherit; + font-family: inherit; + position: relative; + z-index: 0; + box-sizing: border-box; + padding: 4px 0; + + &::placeholder { + color: $ccui-placeholder; + } + + &:disabled { + cursor: not-allowed; + color: $ccui-disabled-text; + } + } + + &__prefix, + &__suffix { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: $ccui-placeholder; + pointer-events: none; + z-index: 1; + } + + &__prefix { + left: 8px; + } + + &__suffix { + right: 8px; + } + + &__clear, + &__password-visible, + &__password-hidden { + cursor: pointer; + pointer-events: auto; + color: $ccui-placeholder; + transition: color $ccui-animation-duration-base; + + &:hover { + color: $ccui-text; + } + } +} diff --git a/packages/ccui/ui/input/src/input.tsx b/packages/ccui/ui/input/src/input.tsx new file mode 100644 index 00000000..051ae7c1 --- /dev/null +++ b/packages/ccui/ui/input/src/input.tsx @@ -0,0 +1,205 @@ +import type { InputProps } from './input-types' +import { computed, defineComponent, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { inputProps } from './input-types' +import './input.scss' + +export default defineComponent({ + name: 'CInput', + props: inputProps, + emits: ['update:modelValue', 'input', 'change', 'focus', 'blur', 'clear'], + setup(props: InputProps, { emit, slots }) { + const ns = useNamespace('input') + const inputRef = ref(null) + const inputValue = ref(props.modelValue) + const isFocused = ref(false) + const isPasswordVisible = ref(false) + + // 计算属性 + const showPasswordVisible = computed(() => props.type === 'password' && props.showPassword) + + const currentType = computed(() => { + if (props.type === 'password' && showPasswordVisible.value && isPasswordVisible.value) { + return 'text' + } + return props.type + }) + + // 为测试兼容性,保持一致的类名结构 + const getBaseClass = computed(() => { + const hasInteractiveSuffix = !props.disabled && !props.readonly + && (props.clearable || showPasswordVisible.value) + const hasSuffix = hasInteractiveSuffix || !!slots.suffix + + return { + [ns.b()]: true, // 基础类 + [ns.m(props.size)]: !!props.size, // 尺寸类 + [ns.m('disabled')]: props.disabled, // 禁用类 + [ns.m('readonly')]: props.readonly, // 只读类 + [ns.m('clearable')]: props.clearable, + [ns.m('suffix')]: hasSuffix, + [ns.m('prefix')]: !!slots.prefix, + } + }) + + const getWrapperClass = computed(() => { + return { + [ns.e('wrapper')]: true, // wrapper类 + [ns.em('wrapper', props.size)]: !!props.size, // wrapper尺寸类 + [ns.em('wrapper', 'disabled')]: props.disabled, // wrapper禁用类 + } + }) + + const inputClass = computed(() => ({ + [ns.e('inner')]: true, + [ns.m(props.size)]: !!props.size, + [ns.m('disabled')]: props.disabled, + [ns.m('readonly')]: props.readonly, + })) + + // 事件处理 + const updateValue = (value: string) => { + inputValue.value = value + emit('update:modelValue', value) + emit('input', value) + } + + const handleInput = (e: Event) => { + const target = e.target as HTMLInputElement + updateValue(target.value) + } + + const handleChange = (e: Event) => { + const target = e.target as HTMLInputElement + emit('change', target.value) + } + + const handleFocus = (e: FocusEvent) => { + isFocused.value = true + emit('focus', e) + } + + const handleBlur = (e: FocusEvent) => { + isFocused.value = false + emit('blur', e) + } + + const clearInput = () => { + updateValue('') + emit('clear') + } + + const togglePasswordVisible = () => { + isPasswordVisible.value = !isPasswordVisible.value + } + + // 监听 modelValue 属性变化 + watch(() => props.modelValue, (newVal) => { + if (newVal !== inputValue.value) { + inputValue.value = newVal + } + }) + + // 渲染函数 + const renderPrepend = () => { + if (!props.prepend && !slots.prepend) + return null + return ( +
+ {slots.prepend ? slots.prepend() : {props.prepend}} +
+ ) + } + + const renderAppend = () => { + if (!props.append && !slots.append) + return null + return ( +
+ {slots.append ? slots.append() : {props.append}} +
+ ) + } + + const renderSuffixIcons = () => { + const isInteractive = !props.disabled && !props.readonly + const hasClear = isInteractive && props.clearable && inputValue.value + const hasPasswordToggle = isInteractive && showPasswordVisible.value + const hasSuffixSlot = !!slots.suffix + + if (!hasClear && !hasPasswordToggle && !hasSuffixSlot) + return null + + return ( +
+ {hasClear && ( + + )} + {hasPasswordToggle && ( + + )} + {hasSuffixSlot && slots.suffix!()} +
+ ) + } + + const getInputAttrs = () => ({ + ref: inputRef, + class: inputClass.value, + placeholder: props.placeholder, + disabled: props.disabled, + readonly: props.readonly, + value: inputValue.value, + onInput: handleInput, + onChange: handleChange, + onFocus: handleFocus, + onBlur: handleBlur, + }) + + // 渲染主体 + return () => { + const prependContent = renderPrepend() + const appendContent = renderAppend() + + const inputElement = + + const mainContent = ( + <> + {slots.prefix &&
{slots.prefix()}
} + {inputElement} + {renderSuffixIcons()} + + ) + + // 统一结构:始终确保基础类和wrapper类都正确应用 + if (prependContent || appendContent) { + // 有 prepend 或 append 时,基础类在顶层容器,wrapper类在内部容器 + return ( +
+ {prependContent} +
+ {mainContent} +
+ {appendContent} +
+ ) + } + else { + // 没有 prepend 或 append 时,将基础类和wrapper类合并到同一个元素 + const combinedClass = { + ...getBaseClass.value, + ...getWrapperClass.value, + } + + return ( +
+ {mainContent} +
+ ) + } + } + }, +}) diff --git a/packages/ccui/ui/input/test/input.test.ts b/packages/ccui/ui/input/test/input.test.ts new file mode 100644 index 00000000..0e97b37c --- /dev/null +++ b/packages/ccui/ui/input/test/input.test.ts @@ -0,0 +1,205 @@ +import type { InputSize } from '../src/input-types' +import { mount, shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Input } from '../index' + +const ns = useNamespace('input', true) +const baseClass = ns.b() +const _wrapperClass = ns.e('wrapper') +function getSizeClass(type: InputSize) { + return ns.em('wrapper', type) +} + +describe('input', () => { + it('dom', () => { + const wrapper = shallowMount(Input, { + props: { + modelValue: 'test', + }, + }) + + // 元素是否存在 + expect(wrapper.find(baseClass).exists()).toBeTruthy() + + // 输入框是否存在 + expect(wrapper.find('input').exists()).toBeTruthy() + + wrapper.unmount() + }) + + it('type', async () => { + const wrapper = shallowMount(Input, { props: { type: 'text' } }) + expect(wrapper.find('input').attributes('type')).toBe('text') + + await wrapper.setProps({ type: 'password' }) + expect(wrapper.find('input').attributes('type')).toBe('password') + }) + + it('size', async () => { + const wrapper = shallowMount(Input, { props: { size: 'small' } }) + expect(wrapper.find(getSizeClass('small')).exists()).toBeTruthy() + + await wrapper.setProps({ size: 'large' }) + expect(wrapper.find(getSizeClass('large')).exists()).toBeTruthy() + }) + + it('placeholder', async () => { + const wrapper = shallowMount(Input, { props: { placeholder: '请输入内容' } }) + expect(wrapper.find('input').attributes('placeholder')).toBe('请输入内容') + }) + + it('disabled', async () => { + const wrapper = shallowMount(Input, { props: { disabled: true } }) + + // 检查是否应用了禁用样式类 + const disabledClass = ns.m('disabled').substring(1) // 移除开头的点 + expect(wrapper.find('input').classes()).toContain(disabledClass) + + // 检查是否应用了禁用属性 + expect(wrapper.find('input').attributes('disabled')).toBe('') + }) + + it('readonly', async () => { + const wrapper = shallowMount(Input, { props: { readonly: true } }) + + // 检查是否应用了只读属性 + expect(wrapper.find('input').attributes('readonly')).toBe('') + }) + + it('clearable', async () => { + const wrapper = shallowMount(Input, { props: { clearable: true, modelValue: 'test' } }) + + // 检查是否显示清空图标 + expect(wrapper.find(ns.e('clear')).exists()).toBeTruthy() + }) + + it('prepend', async () => { + const wrapper = shallowMount(Input, { props: { prepend: 'http://' } }) + + // 检查是否显示前置内容 + expect(wrapper.find(ns.e('prepend')).exists()).toBeTruthy() + expect(wrapper.find(ns.e('prepend')).text()).toBe('http://') + }) + + it('append', async () => { + const wrapper = shallowMount(Input, { props: { append: '.com' } }) + + // 检查是否显示后置内容 + expect(wrapper.find(ns.e('append')).exists()).toBeTruthy() + expect(wrapper.find(ns.e('append')).text()).toBe('.com') + }) + + it('emits input event when input value changes', async () => { + const handleInput = vi.fn() + const wrapper = mount(Input, { + attrs: { + onInput: handleInput, + }, + }) + + const input = wrapper.find('input') + await input.setValue('test') + expect(handleInput).toBeCalled() + + wrapper.unmount() + }) + + it('emits change event when input value changes and loses focus', async () => { + const handleChange = vi.fn() + const wrapper = mount(Input, { + attrs: { + onChange: handleChange, + }, + }) + + const input = wrapper.find('input') + await input.setValue('test') + await input.trigger('change') + expect(handleChange).toBeCalled() + + wrapper.unmount() + }) + + it('emits focus event when input is focused', async () => { + const handleFocus = vi.fn() + const wrapper = mount(Input, { + attrs: { + onFocus: handleFocus, + }, + }) + + const input = wrapper.find('input') + await input.trigger('focus') + expect(handleFocus).toBeCalled() + + wrapper.unmount() + }) + + it('emits blur event when input loses focus', async () => { + const handleBlur = vi.fn() + const wrapper = mount(Input, { + attrs: { + onBlur: handleBlur, + }, + }) + + const input = wrapper.find('input') + await input.trigger('focus') + await input.trigger('blur') + expect(handleBlur).toBeCalled() + + wrapper.unmount() + }) + + it('clears input value when clear icon is clicked', async () => { + const handleClear = vi.fn() + const wrapper = mount(Input, { + props: { + clearable: true, + modelValue: 'test', + }, + attrs: { + onClear: handleClear, + }, + }) + + const clearIcon = wrapper.find(ns.e('clear')) + await clearIcon.trigger('click') + expect(handleClear).toBeCalled() + + // 检查输入框值是否被清空 + expect(wrapper.find('input').element.value).toBe('') + + wrapper.unmount() + }) + + it('toggles password visibility when showPassword is true', async () => { + const wrapper = mount(Input, { + props: { + type: 'password', + showPassword: true, + modelValue: 'test', + }, + }) + + // 检查是否显示密码切换图标 + expect(wrapper.find(ns.e('password-hidden')).exists()).toBeTruthy() + + // 点击密码切换图标 + const passwordIcon = wrapper.find(ns.e('password-hidden')) + await passwordIcon.trigger('click') + + // 检查密码是否变为可见 + expect(wrapper.find(ns.e('password-visible')).exists()).toBeTruthy() + + // 再次点击密码切换图标 + const passwordIcon2 = wrapper.find(ns.e('password-visible')) + await passwordIcon2.trigger('click') + + // 检查密码是否变为隐藏 + expect(wrapper.find(ns.e('password-hidden')).exists()).toBeTruthy() + + wrapper.unmount() + }) +}) diff --git a/packages/docs/.vitepress/theme/styles/index.css b/packages/docs/.vitepress/theme/styles/index.css index eb83180a..bccf2153 100644 --- a/packages/docs/.vitepress/theme/styles/index.css +++ b/packages/docs/.vitepress/theme/styles/index.css @@ -6,3 +6,19 @@ width: 100%; display: table; } + +.mt-10 { + margin-top: 10px; +} + +.mb-10 { + margin-bottom: 10px; +} + +.ml-10 { + margin-left: 10px; +} + +.mr-10 { + margin-right: 10px; +} diff --git a/packages/docs/components/input/index.md b/packages/docs/components/input/index.md new file mode 100644 index 00000000..a329e945 --- /dev/null +++ b/packages/docs/components/input/index.md @@ -0,0 +1,274 @@ +# Input 输入框 + +通过鼠标或键盘输入字符,可以设置输入框的类型、大小和状态。 + +## 何时使用 + +- 需要用户输入文本内容时。 +- 需要收集用户的简短信息时。 +- 需要搜索、过滤或表单输入时。 + +## 基本使用 + +:::demo + +```vue + + + + + +``` + +::: + +## 不同尺寸 + +:::demo + +```vue + + + + + +``` + +::: + +## 禁用状态 + +:::demo + +```vue + + + + + +``` + +::: + +## 只读状态 + +:::demo + +```vue + + + + + +``` + +::: + +## 清空功能 + +:::demo + +```vue + + + + + +``` + +::: + +## 密码输入 + +:::demo + +```vue + + + + + +``` + +::: + +## 前置/后置内容 + +:::demo + +```vue + + + + + +``` + +::: + +## Input参数 + +| 参数 | 类型 | 默认 | 说明 | +| ------------- | ----------------------- | ------- | -------------------------- | +| type | [InputType](#inputtype) | text | 输入框类型 | +| size | [InputSize](#inputsize) | default | 输入框尺寸 | +| placeholder | string | -- | 占位符 | +| disabled | boolean | false | 是否为禁用状态 | +| readonly | boolean | false | 是否为只读状态 | +| clearable | boolean | false | 是否显示清空图标 | +| show-password | boolean | false | 密码输入时是否可切换可见性 | +| prepend | string | -- | 前置内容文本 | +| append | string | -- | 后置内容文本 | +| modelValue | string | -- | 绑定值 | + +## Input类型定义 + +### InputType + +```ts +export type InputType = 'text' | 'password' +``` + +### InputSize + +```ts +export type InputSize = 'large' | 'default' | 'small' +``` + +## Input事件 + +| 事件名 | 参数 | 说明 | +| -------------- | ----- | -------------------------------- | +| update:modelValue | value | 绑定值改变时触发(v-model 事件) | +| input | value | 输入框值改变时触发 | +| change | value | 输入框值改变并失去焦点时触发 | +| focus | event | 输入框获得焦点时触发 | +| blur | event | 输入框失去焦点时触发 | +| clear | -- | 点击清空图标时触发 | + +## Input插槽 + +| 插槽名 | 说明 | +| ------- | -------- | +| prepend | 前置内容 | +| append | 后置内容 | +| prefix | 前缀图标 | +| suffix | 后缀图标 | diff --git a/packages/theme/themes/light.ts b/packages/theme/themes/light.ts index f4fffd94..0f64cae1 100644 --- a/packages/theme/themes/light.ts +++ b/packages/theme/themes/light.ts @@ -106,7 +106,7 @@ export default { 'font-content-weight': 'normal', 'line-height-base': '1.5', // 圆角 - 'border-radius': '2px', + 'border-radius': '4px', 'border-radius-feedback': '4px', 'border-radius-card': '6px', // 阴影 @@ -133,4 +133,8 @@ export default { 'z-index-modal': '1050', 'z-index-drawer': '1040', 'z-index-framework': '1000', + // 组件的size + 'size-sm': '24px', + 'size-md': '32px', + 'size-lg': '40px', }