From 90824f5ed810836f7471ffc2f265bde7ace2331d Mon Sep 17 00:00:00 2001 From: zcj Date: Mon, 4 May 2026 13:02:31 +0800 Subject: [PATCH 001/277] =?UTF-8?q?feat:=202.x=20=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/alert/index.ts | 17 + packages/ccui/ui/alert/src/alert-types.ts | 36 ++ packages/ccui/ui/alert/src/alert.scss | 113 ++++++ packages/ccui/ui/alert/src/alert.tsx | 78 ++++ packages/ccui/ui/alert/test/alert.test.ts | 34 ++ packages/ccui/ui/avatar/src/avatar.scss | 14 +- packages/ccui/ui/badge/index.ts | 17 + packages/ccui/ui/badge/src/badge-types.ts | 40 ++ packages/ccui/ui/badge/src/badge.scss | 124 +++++++ packages/ccui/ui/badge/src/badge.tsx | 103 ++++++ packages/ccui/ui/badge/test/badge.test.ts | 39 ++ packages/ccui/ui/breadcrumb/index.ts | 21 ++ .../ui/breadcrumb/src/breadcrumb-types.ts | 33 ++ .../ccui/ui/breadcrumb/src/breadcrumb.scss | 50 +++ .../ccui/ui/breadcrumb/src/breadcrumb.tsx | 62 ++++ .../ui/breadcrumb/test/breadcrumb.test.ts | 37 ++ packages/ccui/ui/button/src/button.scss | 248 ++++++++----- packages/ccui/ui/button/src/button.tsx | 8 +- packages/ccui/ui/card/src/card.scss | 29 +- packages/ccui/ui/divider/src/divider.scss | 8 +- packages/ccui/ui/empty/index.ts | 17 + packages/ccui/ui/empty/src/empty-types.ts | 18 + packages/ccui/ui/empty/src/empty.scss | 30 ++ packages/ccui/ui/empty/src/empty.tsx | 44 +++ packages/ccui/ui/empty/test/empty.test.ts | 30 ++ packages/ccui/ui/flex/index.ts | 17 + packages/ccui/ui/flex/src/flex-types.ts | 44 +++ packages/ccui/ui/flex/src/flex.scss | 5 + packages/ccui/ui/flex/src/flex.tsx | 41 +++ packages/ccui/ui/flex/test/flex.test.ts | 32 ++ packages/ccui/ui/input/src/input.scss | 18 +- packages/ccui/ui/progress/index.ts | 17 + .../ccui/ui/progress/src/progress-types.ts | 57 +++ packages/ccui/ui/progress/src/progress.scss | 103 ++++++ packages/ccui/ui/progress/src/progress.tsx | 148 ++++++++ .../ccui/ui/progress/test/progress.test.ts | 37 ++ packages/ccui/ui/result/index.ts | 17 + packages/ccui/ui/result/src/result-types.ts | 24 ++ packages/ccui/ui/result/src/result.scss | 48 +++ packages/ccui/ui/result/src/result.tsx | 86 +++++ packages/ccui/ui/result/test/result.test.ts | 33 ++ packages/ccui/ui/segmented/index.ts | 17 + .../ccui/ui/segmented/src/segmented-types.ts | 44 +++ packages/ccui/ui/segmented/src/segmented.scss | 100 +++++ packages/ccui/ui/segmented/src/segmented.tsx | 67 ++++ .../ccui/ui/segmented/test/segmented.test.ts | 41 +++ packages/ccui/ui/skeleton/index.ts | 17 + .../ccui/ui/skeleton/src/skeleton-types.ts | 34 ++ packages/ccui/ui/skeleton/src/skeleton.scss | 78 ++++ packages/ccui/ui/skeleton/src/skeleton.tsx | 107 ++++++ .../ccui/ui/skeleton/test/skeleton.test.ts | 35 ++ packages/ccui/ui/space/index.ts | 17 + packages/ccui/ui/space/src/space-types.ts | 47 +++ packages/ccui/ui/space/src/space.scss | 43 +++ packages/ccui/ui/space/src/space.tsx | 67 ++++ packages/ccui/ui/space/test/space.test.ts | 38 ++ packages/ccui/ui/spin/index.ts | 17 + packages/ccui/ui/spin/src/spin-types.ts | 32 ++ packages/ccui/ui/spin/src/spin.scss | 128 +++++++ packages/ccui/ui/spin/src/spin.tsx | 97 +++++ packages/ccui/ui/spin/test/spin.test.ts | 30 ++ packages/ccui/ui/status/src/status.scss | 47 +-- packages/ccui/ui/switch/index.ts | 17 + packages/ccui/ui/switch/src/switch-types.ts | 45 +++ packages/ccui/ui/switch/src/switch.scss | 122 ++++++ packages/ccui/ui/switch/src/switch.tsx | 56 +++ packages/ccui/ui/switch/test/switch.test.ts | 41 +++ packages/ccui/ui/tag/index.ts | 17 + packages/ccui/ui/tag/src/tag-types.ts | 65 ++++ packages/ccui/ui/tag/src/tag.scss | 122 ++++++ packages/ccui/ui/tag/src/tag.tsx | 52 +++ packages/ccui/ui/tag/test/tag.test.ts | 36 ++ packages/ccui/ui/typography/index.ts | 19 + .../ui/typography/src/typography-types.ts | 72 ++++ .../ccui/ui/typography/src/typography.scss | 113 ++++++ .../ccui/ui/typography/src/typography.tsx | 121 ++++++ .../ui/typography/test/typography.test.ts | 38 ++ packages/docs/components/alert/index.md | 111 ++++++ packages/docs/components/badge/index.md | 120 ++++++ packages/docs/components/breadcrumb/index.md | 81 ++++ packages/docs/components/empty/index.md | 52 +++ packages/docs/components/flex/index.md | 67 ++++ packages/docs/components/input/index.md | 14 +- packages/docs/components/progress/index.md | 64 ++++ packages/docs/components/result/index.md | 76 ++++ packages/docs/components/segmented/index.md | 78 ++++ packages/docs/components/skeleton/index.md | 56 +++ packages/docs/components/space/index.md | 97 +++++ packages/docs/components/spin/index.md | 77 ++++ packages/docs/components/switch/index.md | 68 ++++ packages/docs/components/tag/index.md | 151 ++++++++ packages/docs/components/typography/index.md | 153 ++++++++ packages/theme/themes/dark.ts | 222 ++++++----- packages/theme/themes/light.ts | 348 ++++++++++++------ 94 files changed, 5519 insertions(+), 332 deletions(-) create mode 100644 packages/ccui/ui/alert/index.ts create mode 100644 packages/ccui/ui/alert/src/alert-types.ts create mode 100644 packages/ccui/ui/alert/src/alert.scss create mode 100644 packages/ccui/ui/alert/src/alert.tsx create mode 100644 packages/ccui/ui/alert/test/alert.test.ts create mode 100644 packages/ccui/ui/badge/index.ts create mode 100644 packages/ccui/ui/badge/src/badge-types.ts create mode 100644 packages/ccui/ui/badge/src/badge.scss create mode 100644 packages/ccui/ui/badge/src/badge.tsx create mode 100644 packages/ccui/ui/badge/test/badge.test.ts create mode 100644 packages/ccui/ui/breadcrumb/index.ts create mode 100644 packages/ccui/ui/breadcrumb/src/breadcrumb-types.ts create mode 100644 packages/ccui/ui/breadcrumb/src/breadcrumb.scss create mode 100644 packages/ccui/ui/breadcrumb/src/breadcrumb.tsx create mode 100644 packages/ccui/ui/breadcrumb/test/breadcrumb.test.ts create mode 100644 packages/ccui/ui/empty/index.ts create mode 100644 packages/ccui/ui/empty/src/empty-types.ts create mode 100644 packages/ccui/ui/empty/src/empty.scss create mode 100644 packages/ccui/ui/empty/src/empty.tsx create mode 100644 packages/ccui/ui/empty/test/empty.test.ts create mode 100644 packages/ccui/ui/flex/index.ts create mode 100644 packages/ccui/ui/flex/src/flex-types.ts create mode 100644 packages/ccui/ui/flex/src/flex.scss create mode 100644 packages/ccui/ui/flex/src/flex.tsx create mode 100644 packages/ccui/ui/flex/test/flex.test.ts create mode 100644 packages/ccui/ui/progress/index.ts create mode 100644 packages/ccui/ui/progress/src/progress-types.ts create mode 100644 packages/ccui/ui/progress/src/progress.scss create mode 100644 packages/ccui/ui/progress/src/progress.tsx create mode 100644 packages/ccui/ui/progress/test/progress.test.ts create mode 100644 packages/ccui/ui/result/index.ts create mode 100644 packages/ccui/ui/result/src/result-types.ts create mode 100644 packages/ccui/ui/result/src/result.scss create mode 100644 packages/ccui/ui/result/src/result.tsx create mode 100644 packages/ccui/ui/result/test/result.test.ts create mode 100644 packages/ccui/ui/segmented/index.ts create mode 100644 packages/ccui/ui/segmented/src/segmented-types.ts create mode 100644 packages/ccui/ui/segmented/src/segmented.scss create mode 100644 packages/ccui/ui/segmented/src/segmented.tsx create mode 100644 packages/ccui/ui/segmented/test/segmented.test.ts create mode 100644 packages/ccui/ui/skeleton/index.ts create mode 100644 packages/ccui/ui/skeleton/src/skeleton-types.ts create mode 100644 packages/ccui/ui/skeleton/src/skeleton.scss create mode 100644 packages/ccui/ui/skeleton/src/skeleton.tsx create mode 100644 packages/ccui/ui/skeleton/test/skeleton.test.ts create mode 100644 packages/ccui/ui/space/index.ts create mode 100644 packages/ccui/ui/space/src/space-types.ts create mode 100644 packages/ccui/ui/space/src/space.scss create mode 100644 packages/ccui/ui/space/src/space.tsx create mode 100644 packages/ccui/ui/space/test/space.test.ts create mode 100644 packages/ccui/ui/spin/index.ts create mode 100644 packages/ccui/ui/spin/src/spin-types.ts create mode 100644 packages/ccui/ui/spin/src/spin.scss create mode 100644 packages/ccui/ui/spin/src/spin.tsx create mode 100644 packages/ccui/ui/spin/test/spin.test.ts create mode 100644 packages/ccui/ui/switch/index.ts create mode 100644 packages/ccui/ui/switch/src/switch-types.ts create mode 100644 packages/ccui/ui/switch/src/switch.scss create mode 100644 packages/ccui/ui/switch/src/switch.tsx create mode 100644 packages/ccui/ui/switch/test/switch.test.ts create mode 100644 packages/ccui/ui/tag/index.ts create mode 100644 packages/ccui/ui/tag/src/tag-types.ts create mode 100644 packages/ccui/ui/tag/src/tag.scss create mode 100644 packages/ccui/ui/tag/src/tag.tsx create mode 100644 packages/ccui/ui/tag/test/tag.test.ts create mode 100644 packages/ccui/ui/typography/index.ts create mode 100644 packages/ccui/ui/typography/src/typography-types.ts create mode 100644 packages/ccui/ui/typography/src/typography.scss create mode 100644 packages/ccui/ui/typography/src/typography.tsx create mode 100644 packages/ccui/ui/typography/test/typography.test.ts create mode 100644 packages/docs/components/alert/index.md create mode 100644 packages/docs/components/badge/index.md create mode 100644 packages/docs/components/breadcrumb/index.md create mode 100644 packages/docs/components/empty/index.md create mode 100644 packages/docs/components/flex/index.md create mode 100644 packages/docs/components/progress/index.md create mode 100644 packages/docs/components/result/index.md create mode 100644 packages/docs/components/segmented/index.md create mode 100644 packages/docs/components/skeleton/index.md create mode 100644 packages/docs/components/space/index.md create mode 100644 packages/docs/components/spin/index.md create mode 100644 packages/docs/components/switch/index.md create mode 100644 packages/docs/components/tag/index.md create mode 100644 packages/docs/components/typography/index.md diff --git a/packages/ccui/ui/alert/index.ts b/packages/ccui/ui/alert/index.ts new file mode 100644 index 00000000..fc52c54d --- /dev/null +++ b/packages/ccui/ui/alert/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Alert from './src/alert' + +Alert.install = function (app: App): void { + app.component(Alert.name!, Alert) +} + +export { Alert } + +export default { + title: 'Alert 警告提示', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Alert.name!, Alert) + }, +} diff --git a/packages/ccui/ui/alert/src/alert-types.ts b/packages/ccui/ui/alert/src/alert-types.ts new file mode 100644 index 00000000..e154ff79 --- /dev/null +++ b/packages/ccui/ui/alert/src/alert-types.ts @@ -0,0 +1,36 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type AlertType = 'success' | 'info' | 'warning' | 'error' + +export const alertProps = { + type: { + type: String as PropType, + default: 'info', + }, + message: { + type: String, + default: '', + }, + description: { + type: String, + default: '', + }, + showIcon: { + type: Boolean, + default: false, + }, + closable: { + type: Boolean, + default: false, + }, + closeText: { + type: String, + default: '', + }, + banner: { + type: Boolean, + default: false, + }, +} as const + +export type AlertProps = ExtractPropTypes diff --git a/packages/ccui/ui/alert/src/alert.scss b/packages/ccui/ui/alert/src/alert.scss new file mode 100644 index 00000000..c68492a3 --- /dev/null +++ b/packages/ccui/ui/alert/src/alert.scss @@ -0,0 +1,113 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-alert { + position: relative; + display: flex; + align-items: center; + padding: 8px 12px; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; + border-radius: $ccui-border-radius; + word-wrap: break-word; + color: $ccui-color-text; + + &__icon { + display: inline-flex; + margin-inline-end: 8px; + font-size: $ccui-font-size-icon; + line-height: 0; + } + + &__content { + flex: 1; + min-width: 0; + } + + &__message { + color: $ccui-color-text; + } + + &__description { + display: none; + color: $ccui-color-text; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; + } + + &__close { + margin-inline-start: 8px; + padding: 0; + color: $ccui-color-text-tertiary; + background: transparent; + border: none; + cursor: pointer; + transition: color $ccui-motion-duration-mid; + line-height: 1; + + &:hover { + color: $ccui-color-text; + } + } + + &--success { + background: $ccui-color-success-bg; + border: 1px solid $ccui-color-success-border; + + .#{$cls-prefix}-alert__icon { + color: $ccui-color-success; + } + } + + &--info { + background: $ccui-color-info-bg; + border: 1px solid $ccui-color-info-border; + + .#{$cls-prefix}-alert__icon { + color: $ccui-color-info; + } + } + + &--warning { + background: $ccui-color-warning-bg; + border: 1px solid $ccui-color-warning-border; + + .#{$cls-prefix}-alert__icon { + color: $ccui-color-warning; + } + } + + &--error { + background: $ccui-color-error-bg; + border: 1px solid $ccui-color-error-border; + + .#{$cls-prefix}-alert__icon { + color: $ccui-color-error; + } + } + + &--with-description { + align-items: flex-start; + padding: 16px; + + .#{$cls-prefix}-alert__icon { + margin-top: 4px; + font-size: 24px; + } + + .#{$cls-prefix}-alert__message { + margin-bottom: 4px; + color: $ccui-color-text-heading; + font-size: $ccui-font-size-lg; + } + + .#{$cls-prefix}-alert__description { + display: block; + } + } + + &--banner { + margin-bottom: 0; + border: 0; + border-radius: 0; + } +} diff --git a/packages/ccui/ui/alert/src/alert.tsx b/packages/ccui/ui/alert/src/alert.tsx new file mode 100644 index 00000000..fbb75d86 --- /dev/null +++ b/packages/ccui/ui/alert/src/alert.tsx @@ -0,0 +1,78 @@ +import type { AlertProps, AlertType } from './alert-types' +import { computed, defineComponent, ref } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { alertProps } from './alert-types' +import './alert.scss' + +const ICON_PATHS: Record = { + success: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 0 1-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z', + info: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z', + warning: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z', + error: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z', +} + +export default defineComponent({ + name: 'CAlert', + props: alertProps, + emits: ['close'], + setup(props: AlertProps, { slots, emit }) { + const ns = useNamespace('alert') + const closed = ref(false) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.type)]: true, + [ns.m('with-description')]: !!props.description || !!slots.description, + [ns.m('show-icon')]: props.showIcon || !!slots.icon, + [ns.m('banner')]: props.banner, + })) + + const onClose = (e: MouseEvent) => { + closed.value = true + emit('close', e) + } + + return () => { + if (closed.value) { + return null + } + const showIcon = props.showIcon || !!slots.icon || props.banner + return ( + + ) + } + }, +}) diff --git a/packages/ccui/ui/alert/test/alert.test.ts b/packages/ccui/ui/alert/test/alert.test.ts new file mode 100644 index 00000000..01ae309c --- /dev/null +++ b/packages/ccui/ui/alert/test/alert.test.ts @@ -0,0 +1,34 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Alert } from '../index' + +const ns = useNamespace('alert', true) + +describe('alert', () => { + it('renders message and type modifier', () => { + const wrapper = mount(Alert, { props: { type: 'success', message: 'ok' } }) + expect(wrapper.find(ns.m('success')).exists()).toBe(true) + expect(wrapper.text()).toContain('ok') + }) + + it('renders description and toggles modifier', () => { + const wrapper = mount(Alert, { + props: { message: 'm', description: 'd' }, + }) + expect(wrapper.find(ns.m('with-description')).exists()).toBe(true) + expect(wrapper.text()).toContain('d') + }) + + it('shows icon when showIcon', () => { + const wrapper = mount(Alert, { props: { type: 'info', message: 'm', showIcon: true } }) + expect(wrapper.find(ns.e('icon')).exists()).toBe(true) + }) + + it('emits close and unmounts', async () => { + const wrapper = mount(Alert, { props: { message: 'm', closable: true } }) + await wrapper.find(ns.e('close')).trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + expect(wrapper.find(ns.b()).exists()).toBe(false) + }) +}) diff --git a/packages/ccui/ui/avatar/src/avatar.scss b/packages/ccui/ui/avatar/src/avatar.scss index 7861c135..16e98bf1 100644 --- a/packages/ccui/ui/avatar/src/avatar.scss +++ b/packages/ccui/ui/avatar/src/avatar.scss @@ -2,18 +2,24 @@ .#{$cls-prefix}-avatar { display: inline-block; + vertical-align: middle; &__style { - display: inline-block; - color: rgb(0 0 0 / 50%); + display: inline-flex; + align-items: center; + justify-content: center; + color: $ccui-color-text-light-solid; + background-color: $ccui-color-fill; text-align: center; + line-height: 1; + overflow: hidden; } &--background-0 { - background-color: #ff8b87; + background-color: #f56a00; } &--background-1 { - background-color: #7693f5; + background-color: $ccui-color-primary; } } diff --git a/packages/ccui/ui/badge/index.ts b/packages/ccui/ui/badge/index.ts new file mode 100644 index 00000000..8e952f88 --- /dev/null +++ b/packages/ccui/ui/badge/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Badge from './src/badge' + +Badge.install = function (app: App): void { + app.component(Badge.name!, Badge) +} + +export { Badge } + +export default { + title: 'Badge 徽标数', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(Badge.name!, Badge) + }, +} diff --git a/packages/ccui/ui/badge/src/badge-types.ts b/packages/ccui/ui/badge/src/badge-types.ts new file mode 100644 index 00000000..2f39b621 --- /dev/null +++ b/packages/ccui/ui/badge/src/badge-types.ts @@ -0,0 +1,40 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type BadgeStatus = 'success' | 'processing' | 'default' | 'error' | 'warning' + +export const badgeProps = { + count: { + type: [Number, String] as PropType, + default: undefined, + }, + showZero: { + type: Boolean, + default: false, + }, + overflowCount: { + type: Number, + default: 99, + }, + dot: { + type: Boolean, + default: false, + }, + status: { + type: String as PropType, + default: undefined, + }, + text: { + type: String, + default: '', + }, + color: { + type: String, + default: '', + }, + offset: { + type: Array as PropType<[number, number]>, + default: undefined, + }, +} as const + +export type BadgeProps = ExtractPropTypes diff --git a/packages/ccui/ui/badge/src/badge.scss b/packages/ccui/ui/badge/src/badge.scss new file mode 100644 index 00000000..e1108578 --- /dev/null +++ b/packages/ccui/ui/badge/src/badge.scss @@ -0,0 +1,124 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-badge { + position: relative; + display: inline-block; + line-height: 1; + color: unset; + + &__sup { + position: absolute; + top: 0; + right: 0; + z-index: auto; + display: inline-block; + height: 20px; + min-width: 20px; + padding: 0 6px; + color: #ffffff; + font-weight: normal; + font-size: $ccui-font-size-sm; + line-height: 20px; + white-space: nowrap; + text-align: center; + background: $ccui-color-error; + border-radius: 10px; + box-shadow: 0 0 0 1px $ccui-color-bg-container; + transform: translate(50%, -50%); + transform-origin: 100% 0%; + transition: background-color $ccui-motion-duration-mid; + + &--dot { + width: 6px; + height: 6px; + min-width: 6px; + padding: 0; + border-radius: 100%; + } + } + + &--count-standalone { + display: inline-block; + height: 20px; + min-width: 20px; + padding: 0 6px; + color: #ffffff; + font-size: $ccui-font-size-sm; + line-height: 20px; + text-align: center; + background: $ccui-color-error; + border-radius: 10px; + transform: none; + box-shadow: none; + } + + &--dot-standalone { + display: inline-block; + width: 6px; + height: 6px; + background: $ccui-color-error; + border-radius: 100%; + transform: none; + box-shadow: none; + } + + &__status-dot { + position: relative; + top: -1px; + display: inline-block; + width: 6px; + height: 6px; + vertical-align: middle; + border-radius: 50%; + background: $ccui-color-text-disabled; + + &--success { + background: $ccui-color-success; + } + + &--error { + background: $ccui-color-error; + } + + &--warning { + background: $ccui-color-warning; + } + + &--default { + background: $ccui-color-text-disabled; + } + + &--processing { + background: $ccui-color-primary; + + &::after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 1px solid $ccui-color-primary; + border-radius: 50%; + animation: ccuiBadgeProcessing 1.2s infinite ease-in-out; + content: ''; + } + } + } + + &__status-text { + margin-inline-start: 8px; + color: $ccui-color-text; + font-size: $ccui-font-size; + } +} + +@keyframes ccuiBadgeProcessing { + 0% { + transform: scale(0.8); + opacity: 0.5; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +} diff --git a/packages/ccui/ui/badge/src/badge.tsx b/packages/ccui/ui/badge/src/badge.tsx new file mode 100644 index 00000000..22278c95 --- /dev/null +++ b/packages/ccui/ui/badge/src/badge.tsx @@ -0,0 +1,103 @@ +import type { CSSProperties } from 'vue' +import type { BadgeProps } from './badge-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { badgeProps } from './badge-types' +import './badge.scss' + +export default defineComponent({ + name: 'CBadge', + props: badgeProps, + setup(props: BadgeProps, { slots }) { + const ns = useNamespace('badge') + + const isStandalone = computed(() => !slots.default) + + // 是否显示数字 / dot + const hasCount = computed(() => { + if (props.dot) { + return true + } + const count = props.count + if (count === null || count === undefined || count === '') { + return false + } + if (typeof count === 'number' && count === 0 && !props.showZero) { + return false + } + return true + }) + + const displayCount = computed(() => { + if (props.dot) { + return '' + } + const count = props.count + if (typeof count === 'number') { + return count > props.overflowCount ? `${props.overflowCount}+` : String(count) + } + return String(count ?? '') + }) + + const countStyle = computed(() => { + const style: CSSProperties = {} + if (props.color) { + style.backgroundColor = props.color + } + if (props.offset && Array.isArray(props.offset)) { + const [x, y] = props.offset + style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))` + } + return Object.keys(style).length ? style : undefined + }) + + return () => { + // 状态点 + 文本 + if (props.status || (props.color && !slots.default && !hasCount.value)) { + return ( + + + {props.text && {props.text}} + + ) + } + + // 独立模式 - count 或 dot + if (isStandalone.value) { + if (!hasCount.value) { + return null + } + if (props.dot) { + return + } + return ( + + {displayCount.value} + + ) + } + + // 包裹模式 + const sup = hasCount.value + ? ( + + {props.dot ? null : displayCount.value} + + ) + : null + + return ( + + {slots.default()} + {sup} + + ) + } + }, +}) diff --git a/packages/ccui/ui/badge/test/badge.test.ts b/packages/ccui/ui/badge/test/badge.test.ts new file mode 100644 index 00000000..09c6d18a --- /dev/null +++ b/packages/ccui/ui/badge/test/badge.test.ts @@ -0,0 +1,39 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Badge } from '../index' + +const ns = useNamespace('badge', true) + +describe('badge', () => { + it('renders count number', () => { + const wrapper = mount(Badge, { props: { count: 5 } }) + expect(wrapper.text()).toBe('5') + }) + + it('shows overflow when count exceeds limit', () => { + const wrapper = mount(Badge, { props: { count: 200, overflowCount: 99 } }) + expect(wrapper.text()).toBe('99+') + }) + + it('hides zero by default', () => { + const wrapper = mount(Badge, { props: { count: 0 } }) + expect(wrapper.find(ns.b()).exists()).toBe(false) + }) + + it('shows zero when showZero is true', () => { + const wrapper = mount(Badge, { props: { count: 0, showZero: true } }) + expect(wrapper.text()).toBe('0') + }) + + it('renders status dot', () => { + const wrapper = mount(Badge, { props: { status: 'success', text: 'Success' } }) + expect(wrapper.find(ns.em('status-dot', 'success')).exists()).toBe(true) + expect(wrapper.text()).toContain('Success') + }) + + it('renders dot when dot prop is true', () => { + const wrapper = mount(Badge, { props: { dot: true }, slots: { default: 'link' } }) + expect(wrapper.find(ns.em('sup', 'dot')).exists()).toBe(true) + }) +}) diff --git a/packages/ccui/ui/breadcrumb/index.ts b/packages/ccui/ui/breadcrumb/index.ts new file mode 100644 index 00000000..5855ccb1 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/index.ts @@ -0,0 +1,21 @@ +import type { App } from 'vue' +import { Breadcrumb, BreadcrumbItem } from './src/breadcrumb' + +; + +(Breadcrumb as any).install = function (app: App): void { + app.component((Breadcrumb as any).name, Breadcrumb) + app.component((BreadcrumbItem as any).name, BreadcrumbItem) +} + +export { Breadcrumb, BreadcrumbItem } + +export default { + title: 'Breadcrumb 面包屑', + category: '导航', + status: '100%', + install(app: App): void { + app.component((Breadcrumb as any).name, Breadcrumb) + app.component((BreadcrumbItem as any).name, BreadcrumbItem) + }, +} diff --git a/packages/ccui/ui/breadcrumb/src/breadcrumb-types.ts b/packages/ccui/ui/breadcrumb/src/breadcrumb-types.ts new file mode 100644 index 00000000..8c20d3a1 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb-types.ts @@ -0,0 +1,33 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export interface BreadcrumbRoute { + path?: string + href?: string + title?: string + breadcrumbName?: string +} + +export const breadcrumbProps = { + separator: { + type: String, + default: '/', + }, + routes: { + type: Array as PropType, + default: () => [], + }, +} as const + +export const breadcrumbItemProps = { + href: { + type: String, + default: '', + }, + separator: { + type: String, + default: '', + }, +} as const + +export type BreadcrumbProps = ExtractPropTypes +export type BreadcrumbItemProps = ExtractPropTypes diff --git a/packages/ccui/ui/breadcrumb/src/breadcrumb.scss b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss new file mode 100644 index 00000000..b2f2d992 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss @@ -0,0 +1,50 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + color: $ccui-color-text-secondary; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; + + &__item { + display: inline-flex; + align-items: center; + } + + &__link { + color: $ccui-color-text-secondary; + text-decoration: none; + transition: color $ccui-motion-duration-mid; + + &:hover { + color: $ccui-color-text; + } + } + + a.#{$cls-prefix}-breadcrumb__link { + padding: 0 4px; + color: $ccui-color-text; + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + border-radius: $ccui-border-radius-sm; + } + } + + &__item:last-child &__link { + color: $ccui-color-text; + } + + &__separator { + margin: 0 8px; + color: $ccui-color-split; + user-select: none; + } + + &__item:last-child &__separator { + display: none; + } +} diff --git a/packages/ccui/ui/breadcrumb/src/breadcrumb.tsx b/packages/ccui/ui/breadcrumb/src/breadcrumb.tsx new file mode 100644 index 00000000..d39becc1 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.tsx @@ -0,0 +1,62 @@ +import type { BreadcrumbItemProps, BreadcrumbProps } from './breadcrumb-types' +import { defineComponent, inject, provide } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { breadcrumbItemProps, breadcrumbProps } from './breadcrumb-types' +import './breadcrumb.scss' + +const BREADCRUMB_KEY = Symbol('breadcrumb') + +export const Breadcrumb = defineComponent({ + name: 'CBreadcrumb', + props: breadcrumbProps, + setup(props: BreadcrumbProps, { slots }) { + const ns = useNamespace('breadcrumb') + + provide(BREADCRUMB_KEY, { separator: props.separator }) + + const renderRoutes = () => { + const routes = props.routes ?? [] + const total = routes.length + return routes.map((r, idx) => { + const text = r.breadcrumbName ?? r.title ?? '' + const isLast = idx === total - 1 + const link = r.href ?? r.path + return ( + + {isLast || !link + ? {text} + : {text}} + {!isLast && {props.separator}} + + ) + }) + } + + return () => ( + + ) + }, +}) + +export const BreadcrumbItem = defineComponent({ + name: 'CBreadcrumbItem', + props: breadcrumbItemProps, + setup(props: BreadcrumbItemProps, { slots }) { + const ns = useNamespace('breadcrumb') + const ctx = inject<{ separator: string }>(BREADCRUMB_KEY, { separator: '/' }) + + return () => { + const sep = props.separator || ctx.separator + return ( + + {props.href + ? {slots.default?.()} + : {slots.default?.()}} + {slots.separator ? slots.separator() : sep} + + ) + } + }, +}) diff --git a/packages/ccui/ui/breadcrumb/test/breadcrumb.test.ts b/packages/ccui/ui/breadcrumb/test/breadcrumb.test.ts new file mode 100644 index 00000000..cd8fc5d6 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/test/breadcrumb.test.ts @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Breadcrumb, BreadcrumbItem } from '../index' + +const ns = useNamespace('breadcrumb', true) + +describe('breadcrumb', () => { + it('renders routes via prop', () => { + const wrapper = mount(Breadcrumb, { + props: { + routes: [ + { breadcrumbName: 'Home', href: '/' }, + { breadcrumbName: 'List', path: '/list' }, + { breadcrumbName: 'Detail' }, + ], + }, + }) + expect(wrapper.findAll(ns.e('item')).length).toBe(3) + expect(wrapper.findAll('a').length).toBe(2) + }) + + it('renders custom items via slot', () => { + const wrapper = mount({ + components: { Breadcrumb, BreadcrumbItem }, + template: ` + + Home + Section + + `, + }) + expect(wrapper.findAll(ns.e('item')).length).toBe(2) + expect(wrapper.text()).toContain('Home') + expect(wrapper.text()).toContain('Section') + }) +}) diff --git a/packages/ccui/ui/button/src/button.scss b/packages/ccui/ui/button/src/button.scss index 57bb6298..32e54315 100644 --- a/packages/ccui/ui/button/src/button.scss +++ b/packages/ccui/ui/button/src/button.scss @@ -1,88 +1,153 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-button { - --primary-color: #7693f5; - --success-color: #67c23a; - --info-color: #909399; - --warning-color: #e6a23c; - --danger-color: #f56c6c; - --primary-light-color: rgb(118 147 245 / 10%); - --success-light-color: rgb(103 194 58 / 10%); - --info-light-color: rgb(144 147 153 / 10%); - --warning-light-color: rgb(230 162 60 / 10%); - --danger-light-color: rgb(245 108 108 / 10%); - box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - padding: 8px 15px; - height: 32px; - color: $ccui-text; - font-size: 14px; - font-weight: 500; - line-height: 1; + gap: 8px; + height: $ccui-control-height; + padding: 4px 15px; + color: $ccui-color-text; + font-size: $ccui-font-size; + font-weight: 400; + line-height: $ccui-line-height-base; text-align: center; white-space: nowrap; vertical-align: middle; cursor: pointer; user-select: none; - background-color: $ccui-base-bg; + background-color: $ccui-color-bg-container; + border: 1px solid $ccui-color-border; border-radius: $ccui-border-radius; outline: none; - transition: 0.1s; appearance: none; - border: 1px solid transparent; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.02); + transition: all $ccui-motion-duration-mid $ccui-motion-ease-in-out; &:hover { - opacity: 0.8; + color: $ccui-color-primary-hover; + border-color: $ccui-color-primary-hover; + } + + &:active { + color: $ccui-color-primary-active; + border-color: $ccui-color-primary-active; } &:disabled { cursor: not-allowed; + color: $ccui-color-text-disabled; + background-color: rgba(0, 0, 0, 0.04); + border-color: $ccui-color-border; + box-shadow: none; } + // 实色按钮 &--primary, &--success, &--info, &--warning, &--danger { - color: #ffffff; + color: $ccui-color-text-light-solid; + border-color: transparent; + box-shadow: 0 2px 0 rgba(5, 145, 255, 0.1); - // 获取焦点、选中 - &:focus, - &:active { - opacity: 0.8; + &:hover { + color: $ccui-color-text-light-solid; + } + + &:disabled { + color: $ccui-color-text-disabled; + background-color: rgba(0, 0, 0, 0.04); + border-color: $ccui-color-border; + box-shadow: none; } } &--primary { - background: var(--primary-color); + background-color: $ccui-color-primary; + + &:hover { + background-color: $ccui-color-primary-hover; + border-color: $ccui-color-primary-hover; + } + + &:active { + background-color: $ccui-color-primary-active; + border-color: $ccui-color-primary-active; + } } &--success { - background: var(--success-color); + background-color: $ccui-color-success; + + &:hover { + background-color: $ccui-color-success-hover; + border-color: $ccui-color-success-hover; + } + + &:active { + background-color: $ccui-color-success-active; + border-color: $ccui-color-success-active; + } } &--info { - background: var(--info-color); + background-color: $ccui-color-info; + + &:hover { + background-color: $ccui-color-info-hover; + border-color: $ccui-color-info-hover; + } } &--warning { - background: var(--warning-color); + background-color: $ccui-color-warning; + + &:hover { + background-color: $ccui-color-warning-hover; + border-color: $ccui-color-warning-hover; + } + + &:active { + background-color: $ccui-color-warning-active; + border-color: $ccui-color-warning-active; + } } &--danger { - background: var(--danger-color); + background-color: $ccui-color-error; + + &:hover { + background-color: $ccui-color-error-hover; + border-color: $ccui-color-error-hover; + } + + &:active { + background-color: $ccui-color-error-active; + border-color: $ccui-color-error-active; + } } &--text { - color: #7693f5; + color: $ccui-color-primary; background: transparent; border-color: transparent; + box-shadow: none; + + &:hover { + color: $ccui-color-primary-hover; + background: rgba(0, 0, 0, 0.06); + } + + &:active { + color: $ccui-color-primary-active; + background: rgba(0, 0, 0, 0.15); + } } - // plain 朴素按钮 + // plain 朴素按钮:浅色背景 + 主色边框 &--plain-primary, &--plain-success, &--plain-info, @@ -90,108 +155,125 @@ &--plain-danger { &:focus, &:active { - color: #ffffff; + color: $ccui-color-text-light-solid; } } &--plain-primary { - color: var(--primary-color); - background: var(--primary-light-color); - border-color: var(--primary-color); + color: $ccui-color-primary; + background-color: $ccui-color-primary-bg; + border-color: $ccui-color-primary-border; + + &:hover { + color: $ccui-color-primary-hover; + background-color: $ccui-color-primary-bg-hover; + border-color: $ccui-color-primary-border-hover; + } - &:focus, &:active { - background: var(--primary-color); + background-color: $ccui-color-primary-active; + border-color: $ccui-color-primary-active; } } &--plain-success { - color: var(--success-color); - background: var(--success-light-color); - border-color: var(--success-color); + color: $ccui-color-success; + background-color: $ccui-color-success-bg; + border-color: $ccui-color-success-border; + + &:hover { + color: $ccui-color-success-hover; + } - &:focus, &:active { - background: var(--success-color); + background-color: $ccui-color-success; + border-color: $ccui-color-success; } } &--plain-info { - color: var(--info-color); - background: var(--info-light-color); - border-color: var(--info-color); - - &:focus, - &:active { - background: var(--info-color); - } + color: $ccui-color-info; + background-color: $ccui-color-info-bg; + border-color: $ccui-color-info-border; } &--plain-warning { - color: var(--warning-color); - background: var(--warning-light-color); - border-color: var(--warning-color); + color: $ccui-color-warning; + background-color: $ccui-color-warning-bg; + border-color: $ccui-color-warning-border; + + &:hover { + color: $ccui-color-warning-hover; + } - &:focus, &:active { - background: var(--warning-color); + background-color: $ccui-color-warning; + border-color: $ccui-color-warning; } } &--plain-danger { - color: var(--danger-color); - background: var(--danger-light-color); - border-color: var(--danger-color); + color: $ccui-color-error; + background-color: $ccui-color-error-bg; + border-color: $ccui-color-error-border; + + &:hover { + color: $ccui-color-error-hover; + } - &:focus, &:active { - background: var(--danger-color); + background-color: $ccui-color-error; + border-color: $ccui-color-error; } } // 大小 &--large { - height: 40px; - padding: 12px 19px; - font-size: 16px; + height: $ccui-control-height-lg; + padding: 6px 15px; + font-size: $ccui-font-size-lg; + border-radius: $ccui-border-radius-lg; } &--small { - height: 24px; - padding: 5px 11px; - font-size: 12px; + height: $ccui-control-height-sm; + padding: 0 7px; + font-size: $ccui-font-size; + border-radius: $ccui-border-radius-sm; } // 圆形按钮的不同尺寸 &.#{$cls-prefix}-button--large.#{$cls-prefix}-button--circle { - width: 40px; - height: 40px; - padding: 12px; + width: $ccui-control-height-lg; + padding: 0; } &.#{$cls-prefix}-button--small.#{$cls-prefix}-button--circle { - width: 24px; - height: 24px; - padding: 5px; + width: $ccui-control-height-sm; + padding: 0; } // 圆角 &--round { - border-radius: 20px; + border-radius: 100px; } // 圆形 &--circle { - padding: 8px; + width: $ccui-control-height; + padding: 0; + gap: 0; border-radius: 50%; - width: 32px; - height: 32px; + + .#{$cls-prefix}-button__content:empty { + display: none; + } } // 加载状态 &--loading { cursor: default; - opacity: 0.6; + opacity: 0.65; pointer-events: none; } @@ -208,16 +290,12 @@ display: inline-block; width: 14px; height: 14px; - margin-right: 5px; vertical-align: middle; &::before { content: ''; position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: 0; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; @@ -227,7 +305,7 @@ // 图标 i { - margin-right: 5px; + line-height: 1; } } diff --git a/packages/ccui/ui/button/src/button.tsx b/packages/ccui/ui/button/src/button.tsx index ab3fa378..a95ac5ce 100644 --- a/packages/ccui/ui/button/src/button.tsx +++ b/packages/ccui/ui/button/src/button.tsx @@ -43,9 +43,11 @@ export default defineComponent({ > {props.loading && } {slots.icon ? slots.icon() : props.icon && } - - {slots.default && slots.default()} - + {slots.default && ( + + {slots.default()} + + )} ) } diff --git a/packages/ccui/ui/card/src/card.scss b/packages/ccui/ui/card/src/card.scss index fde98891..d99b804b 100644 --- a/packages/ccui/ui/card/src/card.scss +++ b/packages/ccui/ui/card/src/card.scss @@ -1,28 +1,39 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-card { + position: relative; overflow: hidden; - color: $ccui-text; - background: $ccui-global-bg-normal; - border: 1px solid $ccui-line; - border-radius: $ccui-border-radius-card; - transition: 0.3s; + color: $ccui-color-text; + background: $ccui-color-bg-container; + border: 1px solid $ccui-color-border-secondary; + border-radius: $ccui-border-radius-lg; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; + transition: + box-shadow $ccui-motion-duration-mid, + border-color $ccui-motion-duration-mid; &__header { box-sizing: border-box; - padding: 18px 20px; - border-bottom: 1px solid $ccui-dividing-line; + padding: 0 24px; + min-height: 56px; + display: flex; + align-items: center; + color: $ccui-color-text-heading; + font-weight: 600; + font-size: $ccui-font-size-lg; + border-bottom: 1px solid $ccui-color-border-secondary; } } .#{$cls-prefix}-card--always-shadow { - box-shadow: $ccui-shadow-length-connected-overlay rgb(0 0 0 / 10%); + box-shadow: $ccui-box-shadow-tertiary; } .#{$cls-prefix}-card--hover-shadow { &:hover, &:focus { - box-shadow: $ccui-shadow-length-connected-overlay rgb(0 0 0 / 10%); + box-shadow: $ccui-box-shadow; } } diff --git a/packages/ccui/ui/divider/src/divider.scss b/packages/ccui/ui/divider/src/divider.scss index e041d7c4..d5cc77eb 100644 --- a/packages/ccui/ui/divider/src/divider.scss +++ b/packages/ccui/ui/divider/src/divider.scss @@ -19,11 +19,11 @@ &__text { position: absolute; - padding: 0 20px; - font-size: 14px; + padding: 0 16px; + font-size: $ccui-font-size; font-weight: 500; - color: $ccui-text; - background-color: $ccui-base-bg; + color: $ccui-color-text-heading; + background-color: $ccui-color-bg-container; } .is-left { diff --git a/packages/ccui/ui/empty/index.ts b/packages/ccui/ui/empty/index.ts new file mode 100644 index 00000000..ac739296 --- /dev/null +++ b/packages/ccui/ui/empty/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Empty from './src/empty' + +Empty.install = function (app: App): void { + app.component(Empty.name!, Empty) +} + +export { Empty } + +export default { + title: 'Empty 空状态', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(Empty.name!, Empty) + }, +} diff --git a/packages/ccui/ui/empty/src/empty-types.ts b/packages/ccui/ui/empty/src/empty-types.ts new file mode 100644 index 00000000..a31e4f4a --- /dev/null +++ b/packages/ccui/ui/empty/src/empty-types.ts @@ -0,0 +1,18 @@ +import type { ExtractPropTypes } from 'vue' + +export const emptyProps = { + description: { + type: String, + default: '暂无数据', + }, + image: { + type: String, + default: '', + }, + imageStyle: { + type: Object, + default: () => ({}), + }, +} as const + +export type EmptyProps = ExtractPropTypes diff --git a/packages/ccui/ui/empty/src/empty.scss b/packages/ccui/ui/empty/src/empty.scss new file mode 100644 index 00000000..6530b7c5 --- /dev/null +++ b/packages/ccui/ui/empty/src/empty.scss @@ -0,0 +1,30 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-empty { + margin: 0 8px; + padding: 16px; + text-align: center; + color: $ccui-color-text-disabled; + + &__image { + height: 100px; + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + + img, + svg { + max-height: 100%; + } + } + + &__description { + color: $ccui-color-text-disabled; + font-size: $ccui-font-size; + } + + &__footer { + margin-top: 16px; + } +} diff --git a/packages/ccui/ui/empty/src/empty.tsx b/packages/ccui/ui/empty/src/empty.tsx new file mode 100644 index 00000000..55ba28fc --- /dev/null +++ b/packages/ccui/ui/empty/src/empty.tsx @@ -0,0 +1,44 @@ +import type { EmptyProps } from './empty-types' +import { defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { emptyProps } from './empty-types' +import './empty.scss' + +const DEFAULT_IMG = ( + + + + + + + + + +) + +export default defineComponent({ + name: 'CEmpty', + props: emptyProps, + setup(props: EmptyProps, { slots }) { + const ns = useNamespace('empty') + + return () => ( +
+
+ {slots.image + ? slots.image() + : props.image + ? empty + : DEFAULT_IMG} +
+
+ {slots.description ? slots.description() : props.description} +
+ {slots.default &&
{slots.default()}
} +
+ ) + }, +}) diff --git a/packages/ccui/ui/empty/test/empty.test.ts b/packages/ccui/ui/empty/test/empty.test.ts new file mode 100644 index 00000000..b860820e --- /dev/null +++ b/packages/ccui/ui/empty/test/empty.test.ts @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Empty } from '../index' + +const ns = useNamespace('empty', true) + +describe('empty', () => { + it('renders default description', () => { + const wrapper = mount(Empty) + expect(wrapper.find(ns.b()).exists()).toBe(true) + expect(wrapper.text()).toContain('暂无数据') + }) + + it('renders custom description', () => { + const wrapper = mount(Empty, { props: { description: '空空如也' } }) + expect(wrapper.text()).toContain('空空如也') + }) + + it('renders custom image src', () => { + const wrapper = mount(Empty, { props: { image: '/foo.png' } }) + expect(wrapper.find('img').attributes('src')).toBe('/foo.png') + }) + + it('renders footer slot', () => { + const wrapper = mount(Empty, { slots: { default: '' } }) + expect(wrapper.find(ns.e('footer')).exists()).toBe(true) + expect(wrapper.text()).toContain('retry') + }) +}) diff --git a/packages/ccui/ui/flex/index.ts b/packages/ccui/ui/flex/index.ts new file mode 100644 index 00000000..50a428d4 --- /dev/null +++ b/packages/ccui/ui/flex/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Flex from './src/flex' + +Flex.install = function (app: App): void { + app.component(Flex.name!, Flex) +} + +export { Flex } + +export default { + title: 'Flex 弹性布局', + category: '布局', + status: '100%', + install(app: App): void { + app.component(Flex.name!, Flex) + }, +} diff --git a/packages/ccui/ui/flex/src/flex-types.ts b/packages/ccui/ui/flex/src/flex-types.ts new file mode 100644 index 00000000..776f47ca --- /dev/null +++ b/packages/ccui/ui/flex/src/flex-types.ts @@ -0,0 +1,44 @@ +import type { CSSProperties, ExtractPropTypes, PropType } from 'vue' + +export type FlexJustify = CSSProperties['justifyContent'] +export type FlexAlign = CSSProperties['alignItems'] +export type FlexWrap = CSSProperties['flexWrap'] + +export const flexProps = { + vertical: { + type: Boolean, + default: false, + }, + wrap: { + type: [Boolean, String] as PropType, + default: false, + }, + justify: { + type: String as PropType, + default: 'normal', + }, + align: { + type: String as PropType, + default: 'normal', + }, + flex: { + type: String, + default: 'normal', + }, + gap: { + type: [String, Number] as PropType, + default: undefined, + }, + component: { + type: String, + default: 'div', + }, +} as const + +export type FlexProps = ExtractPropTypes + +export const PRESET_GAP: Record = { + small: '8px', + middle: '16px', + large: '24px', +} diff --git a/packages/ccui/ui/flex/src/flex.scss b/packages/ccui/ui/flex/src/flex.scss new file mode 100644 index 00000000..77393aba --- /dev/null +++ b/packages/ccui/ui/flex/src/flex.scss @@ -0,0 +1,5 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-flex { + display: flex; +} diff --git a/packages/ccui/ui/flex/src/flex.tsx b/packages/ccui/ui/flex/src/flex.tsx new file mode 100644 index 00000000..ff21c146 --- /dev/null +++ b/packages/ccui/ui/flex/src/flex.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from 'vue' +import type { FlexProps } from './flex-types' +import { computed, defineComponent, h } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { flexProps, PRESET_GAP } from './flex-types' +import './flex.scss' + +export default defineComponent({ + name: 'CFlex', + props: flexProps, + setup(props: FlexProps, { slots }) { + const ns = useNamespace('flex') + + const style = computed(() => { + const wrapValue = props.wrap === true ? 'wrap' : props.wrap === false ? 'nowrap' : props.wrap + let gapValue: string | undefined + if (props.gap !== undefined && props.gap !== null) { + if (typeof props.gap === 'number') { + gapValue = `${props.gap}px` + } + else { + gapValue = PRESET_GAP[props.gap] ?? props.gap + } + } + return { + flexDirection: props.vertical ? 'column' : 'row', + flexWrap: wrapValue, + justifyContent: props.justify, + alignItems: props.align, + flex: props.flex, + gap: gapValue, + } + }) + + return () => h( + props.component || 'div', + { class: ns.b(), style: style.value }, + slots.default?.(), + ) + }, +}) diff --git a/packages/ccui/ui/flex/test/flex.test.ts b/packages/ccui/ui/flex/test/flex.test.ts new file mode 100644 index 00000000..2d490b59 --- /dev/null +++ b/packages/ccui/ui/flex/test/flex.test.ts @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Flex } from '../index' + +const ns = useNamespace('flex', true) + +describe('flex', () => { + it('renders div by default', () => { + const wrapper = mount(Flex, { slots: { default: 'x' } }) + expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.find(ns.b()).exists()).toBe(true) + }) + + it('vertical direction sets column', () => { + const wrapper = mount(Flex, { props: { vertical: true }, slots: { default: 'x' } }) + const style = wrapper.attributes('style') ?? '' + expect(style).toContain('flex-direction: column') + }) + + it('preset gap maps to value', () => { + const wrapper = mount(Flex, { props: { gap: 'small' }, slots: { default: 'x' } }) + const style = wrapper.attributes('style') ?? '' + expect(style).toContain('gap: 8px') + }) + + it('numeric gap', () => { + const wrapper = mount(Flex, { props: { gap: 12 }, slots: { default: 'x' } }) + const style = wrapper.attributes('style') ?? '' + expect(style).toContain('gap: 12px') + }) +}) diff --git a/packages/ccui/ui/input/src/input.scss b/packages/ccui/ui/input/src/input.scss index 7cc78e53..c71127a2 100644 --- a/packages/ccui/ui/input/src/input.scss +++ b/packages/ccui/ui/input/src/input.scss @@ -5,22 +5,22 @@ align-items: center; width: 100%; box-sizing: border-box; - background-color: $ccui-base-bg; - border: 1px solid $ccui-line; + background-color: $ccui-color-bg-container; + border: 1px solid $ccui-color-border; border-radius: $ccui-border-radius; - color: $ccui-text; - font-size: 14px; + color: $ccui-color-text; + font-size: $ccui-font-size; transition: - border-color $ccui-animation-duration-base, - box-shadow $ccui-animation-duration-base; + border-color $ccui-motion-duration-mid, + box-shadow $ccui-motion-duration-mid; &:hover { - border-color: $ccui-brand-hover; + border-color: $ccui-color-primary-hover; } &:focus-within { - border-color: $ccui-brand; - box-shadow: 0 0 0 2px rgba($ccui-brand, 0.2); + border-color: $ccui-color-primary; + box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1); } &--disabled { diff --git a/packages/ccui/ui/progress/index.ts b/packages/ccui/ui/progress/index.ts new file mode 100644 index 00000000..3faabbdb --- /dev/null +++ b/packages/ccui/ui/progress/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Progress from './src/progress' + +Progress.install = function (app: App): void { + app.component(Progress.name!, Progress) +} + +export { Progress } + +export default { + title: 'Progress 进度条', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Progress.name!, Progress) + }, +} diff --git a/packages/ccui/ui/progress/src/progress-types.ts b/packages/ccui/ui/progress/src/progress-types.ts new file mode 100644 index 00000000..9745025e --- /dev/null +++ b/packages/ccui/ui/progress/src/progress-types.ts @@ -0,0 +1,57 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type ProgressType = 'line' | 'circle' | 'dashboard' +export type ProgressStatus = 'success' | 'exception' | 'normal' | 'active' +export type ProgressSize = 'default' | 'small' + +export const progressProps = { + percent: { + type: Number, + default: 0, + }, + type: { + type: String as PropType, + default: 'line', + }, + status: { + type: String as PropType, + default: 'normal', + }, + showInfo: { + type: Boolean, + default: true, + }, + strokeColor: { + type: String, + default: '', + }, + trailColor: { + type: String, + default: '', + }, + strokeWidth: { + type: Number, + default: undefined, + }, + width: { + type: Number, + default: 120, + }, + size: { + type: [String, Number, Array] as PropType, + default: 'default', + }, + format: { + type: Function as PropType<(percent: number) => string>, + default: undefined, + }, +} as const + +export type ProgressProps = ExtractPropTypes + +export function clampPercent(p: number): number { + if (Number.isNaN(p)) { + return 0 + } + return Math.min(100, Math.max(0, p)) +} diff --git a/packages/ccui/ui/progress/src/progress.scss b/packages/ccui/ui/progress/src/progress.scss new file mode 100644 index 00000000..c84b44d2 --- /dev/null +++ b/packages/ccui/ui/progress/src/progress.scss @@ -0,0 +1,103 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-progress { + display: inline-block; + width: 100%; + font-size: $ccui-font-size; + color: $ccui-color-text; + + &--line { + display: flex; + align-items: center; + } + + &__outer { + flex: 1; + display: inline-block; + width: 100%; + margin-inline-end: 8px; + padding-inline-end: 0; + } + + &__inner { + position: relative; + display: inline-block; + width: 100%; + overflow: hidden; + vertical-align: middle; + background-color: rgba(0, 0, 0, 0.06); + border-radius: 100px; + } + + &__bg { + position: relative; + background-color: $ccui-color-primary; + border-radius: 100px; + transition: all 0.3s cubic-bezier(0.08, 0.82, 0.17, 1); + } + + &__text { + width: 2em; + margin-inline-start: 8px; + color: $ccui-color-text-secondary; + font-size: $ccui-font-size; + line-height: 1; + white-space: nowrap; + text-align: start; + word-break: normal; + flex-shrink: 0; + + &--status-success { + color: $ccui-color-success; + } + + &--status-exception { + color: $ccui-color-error; + } + } + + &__status-icon { + font-size: 1em; + } + + &--small { + .#{$cls-prefix}-progress__inner { + border-radius: 100px; + } + } + + // 圆形 / 仪表盘 + &--circle, + &--dashboard { + position: relative; + display: inline-block; + } + + &__svg { + width: 100%; + height: 100%; + } + + &__inner-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: $ccui-color-text; + font-size: 1em; + line-height: 1; + white-space: normal; + text-align: center; + + .#{$cls-prefix}-progress--status-success & { + color: $ccui-color-success; + } + + .#{$cls-prefix}-progress--status-exception & { + color: $ccui-color-error; + } + } +} diff --git a/packages/ccui/ui/progress/src/progress.tsx b/packages/ccui/ui/progress/src/progress.tsx new file mode 100644 index 00000000..ad870ab1 --- /dev/null +++ b/packages/ccui/ui/progress/src/progress.tsx @@ -0,0 +1,148 @@ +import type { CSSProperties } from 'vue' +import type { ProgressProps, ProgressStatus } from './progress-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { clampPercent, progressProps } from './progress-types' +import './progress.scss' + +const STATUS_ICONS: Record = { + success: 'M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z', + exception: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z', + normal: null, + active: null, +} + +export default defineComponent({ + name: 'CProgress', + props: progressProps, + setup(props: ProgressProps, { slots }) { + const ns = useNamespace('progress') + + const percent = computed(() => clampPercent(props.percent)) + + const finalStatus = computed(() => { + if (props.status && props.status !== 'normal') { + return props.status + } + return percent.value === 100 ? 'success' : 'normal' + }) + + const trackColor = computed(() => { + if (props.strokeColor) { + return props.strokeColor + } + if (finalStatus.value === 'exception') { + return 'var(--ccui-color-error)' + } + if (finalStatus.value === 'success') { + return 'var(--ccui-color-success)' + } + return 'var(--ccui-color-primary)' + }) + + const renderInfo = () => { + if (!props.showInfo) { + return null + } + if (slots.format) { + return slots.format({ percent: percent.value }) + } + if (props.format) { + return props.format(percent.value) + } + if (finalStatus.value === 'success') { + return ( + + + + ) + } + if (finalStatus.value === 'exception') { + return ( + + + + ) + } + return `${percent.value}%` + } + + const renderLine = () => { + const sizeIsSmall = props.size === 'small' + const height = props.strokeWidth ?? (sizeIsSmall ? 6 : 8) + const lineStyle: CSSProperties = { + width: `${percent.value}%`, + backgroundColor: trackColor.value, + height: `${height}px`, + } + const trailStyle: CSSProperties = { + height: `${height}px`, + backgroundColor: props.trailColor || undefined, + } + + return ( +
+
+
+
+
+
+ {props.showInfo && ( + + {renderInfo()} + + )} +
+ ) + } + + const renderCircle = () => { + const isDashboard = props.type === 'dashboard' + const size = props.width + const radius = 50 - (props.strokeWidth ?? 6) / 2 + const strokeWidth = props.strokeWidth ?? 6 + const circumference = 2 * Math.PI * radius + const dashOffset = circumference * (1 - percent.value / 100) + // 仪表盘:底部缺口 75% + const gapDegree = isDashboard ? 75 : 0 + const dashRatio = (360 - gapDegree) / 360 + const adjustedDashArray = circumference * dashRatio + const adjustedDashOffset = circumference * dashRatio * (1 - percent.value / 100) + const transform = isDashboard + ? `rotate(${gapDegree / 2 + 90}deg)` + : 'rotate(-90deg)' + + return ( +
+ + + + + {props.showInfo && {renderInfo()}} +
+ ) + } + + return () => (props.type === 'line' ? renderLine() : renderCircle()) + }, +}) diff --git a/packages/ccui/ui/progress/test/progress.test.ts b/packages/ccui/ui/progress/test/progress.test.ts new file mode 100644 index 00000000..7418c474 --- /dev/null +++ b/packages/ccui/ui/progress/test/progress.test.ts @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Progress } from '../index' + +const ns = useNamespace('progress', true) + +describe('progress', () => { + it('renders line by default', () => { + const wrapper = mount(Progress, { props: { percent: 30 } }) + expect(wrapper.find(ns.m('line')).exists()).toBe(true) + expect(wrapper.text()).toContain('30%') + }) + + it('clamps percent between 0 and 100', () => { + const wrapper = mount(Progress, { + props: { percent: 150, format: (p: number) => `${p}%` }, + }) + expect(wrapper.text()).toContain('100%') + }) + + it('hides info when showInfo is false', () => { + const wrapper = mount(Progress, { props: { percent: 50, showInfo: false } }) + expect(wrapper.find(ns.e('text')).exists()).toBe(false) + }) + + it('renders circle type', () => { + const wrapper = mount(Progress, { props: { percent: 50, type: 'circle' } }) + expect(wrapper.find(ns.m('circle')).exists()).toBe(true) + expect(wrapper.find('svg').exists()).toBe(true) + }) + + it('exception status renders error class', () => { + const wrapper = mount(Progress, { props: { percent: 50, status: 'exception' } }) + expect(wrapper.find(ns.m('status-exception')).exists()).toBe(true) + }) +}) diff --git a/packages/ccui/ui/result/index.ts b/packages/ccui/ui/result/index.ts new file mode 100644 index 00000000..cb5bd0fe --- /dev/null +++ b/packages/ccui/ui/result/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Result from './src/result' + +Result.install = function (app: App): void { + app.component(Result.name!, Result) +} + +export { Result } + +export default { + title: 'Result 结果', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Result.name!, Result) + }, +} diff --git a/packages/ccui/ui/result/src/result-types.ts b/packages/ccui/ui/result/src/result-types.ts new file mode 100644 index 00000000..5650116f --- /dev/null +++ b/packages/ccui/ui/result/src/result-types.ts @@ -0,0 +1,24 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type ResultStatus = 'success' | 'error' | 'info' | 'warning' | '404' | '403' | '500' + +export const resultProps = { + status: { + type: String as PropType, + default: 'info', + }, + title: { + type: String, + default: '', + }, + subTitle: { + type: String, + default: '', + }, + icon: { + type: String, + default: '', + }, +} as const + +export type ResultProps = ExtractPropTypes diff --git a/packages/ccui/ui/result/src/result.scss b/packages/ccui/ui/result/src/result.scss new file mode 100644 index 00000000..290ed802 --- /dev/null +++ b/packages/ccui/ui/result/src/result.scss @@ -0,0 +1,48 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-result { + padding: 48px 32px; + text-align: center; + + &__icon { + margin-bottom: 24px; + text-align: center; + + svg { + vertical-align: middle; + } + } + + &__title { + color: $ccui-color-text-heading; + font-size: 24px; + line-height: 1.8; + margin-bottom: 8px; + } + + &__subtitle { + color: $ccui-color-text-secondary; + font-size: $ccui-font-size; + line-height: 1.6; + } + + &__extra { + margin-top: 24px; + + > * { + margin-inline-end: 8px; + + &:last-child { + margin-inline-end: 0; + } + } + } + + &__content { + margin-top: 24px; + padding: 24px 40px; + background-color: $ccui-color-fill-quaternary; + border-radius: $ccui-border-radius; + text-align: start; + } +} diff --git a/packages/ccui/ui/result/src/result.tsx b/packages/ccui/ui/result/src/result.tsx new file mode 100644 index 00000000..d1a23f06 --- /dev/null +++ b/packages/ccui/ui/result/src/result.tsx @@ -0,0 +1,86 @@ +import type { ResultProps, ResultStatus } from './result-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { resultProps } from './result-types' +import './result.scss' + +const STATUS_ICON: Record = { + success: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 0 1-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z', + error: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z', + info: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z', + warning: 'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z', +} + +const ICON_COLOR: Record = { + success: 'var(--ccui-color-success)', + error: 'var(--ccui-color-error)', + info: 'var(--ccui-color-info)', + warning: 'var(--ccui-color-warning)', + 404: 'var(--ccui-color-info)', + 403: 'var(--ccui-color-info)', + 500: 'var(--ccui-color-info)', +} + +const STATUS_TEXT: Record = { + 404: { title: '404', subTitle: 'Sorry, the page you visited does not exist.' }, + 403: { title: '403', subTitle: 'Sorry, you are not authorized to access this page.' }, + 500: { title: '500', subTitle: 'Sorry, something went wrong.' }, +} + +export default defineComponent({ + name: 'CResult', + props: resultProps, + setup(props: ResultProps, { slots }) { + const ns = useNamespace('result') + + const isHttp = computed(() => props.status === '404' || props.status === '403' || props.status === '500') + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.status)]: true, + })) + + const renderIcon = () => { + if (slots.icon) { + return slots.icon() + } + const path = STATUS_ICON[props.status] || STATUS_ICON.info + const color = ICON_COLOR[props.status] || 'currentColor' + return ( + + + + ) + } + + const titleText = computed(() => { + if (props.title) { + return props.title + } + if (isHttp.value) { + return STATUS_TEXT[props.status]?.title + } + return '' + }) + + const subTitleText = computed(() => { + if (props.subTitle) { + return props.subTitle + } + if (isHttp.value) { + return STATUS_TEXT[props.status]?.subTitle + } + return '' + }) + + return () => ( +
+
{renderIcon()}
+ {titleText.value &&
{titleText.value}
} + {subTitleText.value &&
{subTitleText.value}
} + {slots.extra &&
{slots.extra()}
} + {slots.default &&
{slots.default()}
} +
+ ) + }, +}) diff --git a/packages/ccui/ui/result/test/result.test.ts b/packages/ccui/ui/result/test/result.test.ts new file mode 100644 index 00000000..d961ea26 --- /dev/null +++ b/packages/ccui/ui/result/test/result.test.ts @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Result } from '../index' + +const ns = useNamespace('result', true) + +describe('result', () => { + it('renders title and subtitle', () => { + const wrapper = mount(Result, { props: { title: 'OK', subTitle: '操作成功' } }) + expect(wrapper.find(ns.e('title')).text()).toBe('OK') + expect(wrapper.find(ns.e('subtitle')).text()).toBe('操作成功') + }) + + it('renders default 404 text', () => { + const wrapper = mount(Result, { props: { status: '404' } }) + expect(wrapper.find(ns.e('title')).text()).toBe('404') + expect(wrapper.find(ns.e('subtitle')).text()).toContain('does not exist') + }) + + it('renders extra slot', () => { + const wrapper = mount(Result, { + props: { title: 'x' }, + slots: { extra: '' }, + }) + expect(wrapper.find('.back').exists()).toBe(true) + }) + + it('applies status modifier', () => { + const wrapper = mount(Result, { props: { status: 'success', title: 'ok' } }) + expect(wrapper.find(ns.m('success')).exists()).toBe(true) + }) +}) diff --git a/packages/ccui/ui/segmented/index.ts b/packages/ccui/ui/segmented/index.ts new file mode 100644 index 00000000..bdf3e2d3 --- /dev/null +++ b/packages/ccui/ui/segmented/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Segmented from './src/segmented' + +Segmented.install = function (app: App): void { + app.component(Segmented.name!, Segmented) +} + +export { Segmented } + +export default { + title: 'Segmented 分段控制器', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(Segmented.name!, Segmented) + }, +} diff --git a/packages/ccui/ui/segmented/src/segmented-types.ts b/packages/ccui/ui/segmented/src/segmented-types.ts new file mode 100644 index 00000000..4b951ef8 --- /dev/null +++ b/packages/ccui/ui/segmented/src/segmented-types.ts @@ -0,0 +1,44 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export interface SegmentedOption { + label?: string + value: string | number + disabled?: boolean + icon?: string +} + +export type SegmentedSize = 'small' | 'middle' | 'large' + +export const segmentedProps = { + modelValue: { + type: [String, Number] as PropType, + default: '', + }, + options: { + type: Array as PropType<(SegmentedOption | string | number)[]>, + default: () => [], + }, + block: { + type: Boolean, + default: false, + }, + disabled: { + type: Boolean, + default: false, + }, + size: { + type: String as PropType, + default: 'middle', + }, +} as const + +export type SegmentedProps = ExtractPropTypes + +export function normalizeOptions(opts: (SegmentedOption | string | number)[]): SegmentedOption[] { + return opts.map((opt) => { + if (typeof opt === 'string' || typeof opt === 'number') { + return { label: String(opt), value: opt } + } + return { ...opt, label: opt.label ?? String(opt.value) } + }) +} diff --git a/packages/ccui/ui/segmented/src/segmented.scss b/packages/ccui/ui/segmented/src/segmented.scss new file mode 100644 index 00000000..be4f94cc --- /dev/null +++ b/packages/ccui/ui/segmented/src/segmented.scss @@ -0,0 +1,100 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-segmented { + display: inline-block; + padding: 2px; + color: $ccui-color-text-secondary; + background-color: rgba(0, 0, 0, 0.04); + border-radius: $ccui-border-radius; + transition: all $ccui-motion-duration-mid; + + &__group { + display: flex; + align-items: stretch; + justify-items: flex-start; + width: 100%; + } + + &__item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + cursor: pointer; + transition: color $ccui-motion-duration-mid; + + &:hover { + color: $ccui-color-text; + } + + &--selected { + color: $ccui-color-text; + background: $ccui-color-bg-elevated; + border-radius: $ccui-border-radius-sm; + box-shadow: + 0 2px 8px -2px rgba(0, 0, 0, 0.05), + 0 1px 4px -1px rgba(25, 15, 15, 0.07), + 0 0 1px 0 rgba(0, 0, 0, 0.08); + } + + &--disabled { + color: $ccui-color-text-disabled; + cursor: not-allowed; + } + } + + &__input { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0; + pointer-events: none; + } + + &__label { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 11px; + line-height: 28px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__icon { + margin-inline-end: 6px; + } + + &--small &__label { + min-height: 24px; + padding: 0 7px; + font-size: $ccui-font-size-sm; + line-height: 24px; + } + + &--large &__label { + min-height: 36px; + padding: 0 11px; + font-size: $ccui-font-size-lg; + line-height: 36px; + } + + &--block { + display: flex; + + .#{$cls-prefix}-segmented__group { + width: 100%; + } + } + + &--disabled { + color: $ccui-color-text-disabled; + cursor: not-allowed; + } +} diff --git a/packages/ccui/ui/segmented/src/segmented.tsx b/packages/ccui/ui/segmented/src/segmented.tsx new file mode 100644 index 00000000..a5788e08 --- /dev/null +++ b/packages/ccui/ui/segmented/src/segmented.tsx @@ -0,0 +1,67 @@ +import type { SegmentedOption, SegmentedProps } from './segmented-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { normalizeOptions, segmentedProps } from './segmented-types' +import './segmented.scss' + +export default defineComponent({ + name: 'CSegmented', + props: segmentedProps, + emits: ['update:modelValue', 'change'], + setup(props: SegmentedProps, { emit, slots }) { + const ns = useNamespace('segmented') + + const list = computed(() => normalizeOptions(props.options)) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m('block')]: props.block, + [ns.m('disabled')]: props.disabled, + [ns.m(props.size)]: true, + })) + + const onSelect = (opt: SegmentedOption) => { + if (props.disabled || opt.disabled || opt.value === props.modelValue) { + return + } + emit('update:modelValue', opt.value) + emit('change', opt.value) + } + + return () => ( +
+
+ {list.value.map(opt => ( + + ))} +
+
+ ) + }, +}) diff --git a/packages/ccui/ui/segmented/test/segmented.test.ts b/packages/ccui/ui/segmented/test/segmented.test.ts new file mode 100644 index 00000000..e2160a78 --- /dev/null +++ b/packages/ccui/ui/segmented/test/segmented.test.ts @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Segmented } from '../index' + +const ns = useNamespace('segmented', true) + +describe('segmented', () => { + it('renders all options', () => { + const wrapper = mount(Segmented, { + props: { modelValue: 'a', options: ['a', 'b', 'c'] }, + }) + expect(wrapper.findAll(ns.e('item')).length).toBe(3) + }) + + it('marks selected item', () => { + const wrapper = mount(Segmented, { + props: { modelValue: 'b', options: ['a', 'b'] }, + }) + expect(wrapper.findAll(ns.em('item', 'selected')).length).toBe(1) + }) + + it('emits update:modelValue on click', async () => { + const wrapper = mount(Segmented, { + props: { modelValue: 'a', options: ['a', 'b'] }, + }) + await wrapper.findAll(ns.e('item'))[1].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['b']) + }) + + it('does not switch on disabled option', async () => { + const wrapper = mount(Segmented, { + props: { + modelValue: 'a', + options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b', disabled: true }], + }, + }) + await wrapper.findAll(ns.e('item'))[1].trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) +}) diff --git a/packages/ccui/ui/skeleton/index.ts b/packages/ccui/ui/skeleton/index.ts new file mode 100644 index 00000000..30167deb --- /dev/null +++ b/packages/ccui/ui/skeleton/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Skeleton from './src/skeleton' + +Skeleton.install = function (app: App): void { + app.component(Skeleton.name!, Skeleton) +} + +export { Skeleton } + +export default { + title: 'Skeleton 骨架屏', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Skeleton.name!, Skeleton) + }, +} diff --git a/packages/ccui/ui/skeleton/src/skeleton-types.ts b/packages/ccui/ui/skeleton/src/skeleton-types.ts new file mode 100644 index 00000000..5ec2adfb --- /dev/null +++ b/packages/ccui/ui/skeleton/src/skeleton-types.ts @@ -0,0 +1,34 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export interface SkeletonAvatarShape { shape?: 'circle' | 'square', size?: 'small' | 'default' | 'large' | number } +export interface SkeletonTitleShape { width?: string | number } +export interface SkeletonParagraphShape { rows?: number, width?: string | number | (string | number)[] } + +export const skeletonProps = { + active: { + type: Boolean, + default: false, + }, + loading: { + type: Boolean, + default: true, + }, + avatar: { + type: [Boolean, Object] as PropType, + default: false, + }, + title: { + type: [Boolean, Object] as PropType, + default: true, + }, + paragraph: { + type: [Boolean, Object] as PropType, + default: true, + }, + round: { + type: Boolean, + default: false, + }, +} as const + +export type SkeletonProps = ExtractPropTypes diff --git a/packages/ccui/ui/skeleton/src/skeleton.scss b/packages/ccui/ui/skeleton/src/skeleton.scss new file mode 100644 index 00000000..f2e7cecd --- /dev/null +++ b/packages/ccui/ui/skeleton/src/skeleton.scss @@ -0,0 +1,78 @@ +@use '../../style-var/index.scss' as *; + +$skeleton-bg: rgba(0, 0, 0, 0.06); +$skeleton-bg-active: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.15) 37%, rgba(0, 0, 0, 0.06) 63%); + +.#{$cls-prefix}-skeleton { + display: flex; + width: 100%; + + &__header { + margin-inline-end: 16px; + } + + &__avatar { + display: inline-block; + background: $skeleton-bg; + width: 32px; + height: 32px; + border-radius: 50%; + } + + &__content { + flex: 1; + width: 100%; + } + + &__title { + width: 38%; + height: 16px; + margin: 0; + background: $skeleton-bg; + border-radius: $ccui-border-radius-sm; + } + + &__paragraph { + margin-top: 24px; + padding: 0; + list-style: none; + + li { + width: 100%; + height: 16px; + margin-top: 16px; + background: $skeleton-bg; + border-radius: $ccui-border-radius-sm; + + &:first-child { + margin-top: 0; + } + } + } + + &--round { + .#{$cls-prefix}-skeleton__title, + .#{$cls-prefix}-skeleton__paragraph li { + border-radius: 100px; + } + } + + &--active { + .#{$cls-prefix}-skeleton__avatar, + .#{$cls-prefix}-skeleton__title, + .#{$cls-prefix}-skeleton__paragraph li { + background: $skeleton-bg-active; + background-size: 400% 100%; + animation: ccuiSkeletonLoading 1.4s ease infinite; + } + } +} + +@keyframes ccuiSkeletonLoading { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0 50%; + } +} diff --git a/packages/ccui/ui/skeleton/src/skeleton.tsx b/packages/ccui/ui/skeleton/src/skeleton.tsx new file mode 100644 index 00000000..2ec29e54 --- /dev/null +++ b/packages/ccui/ui/skeleton/src/skeleton.tsx @@ -0,0 +1,107 @@ +import type { CSSProperties } from 'vue' +import type { + SkeletonAvatarShape, + SkeletonParagraphShape, + SkeletonProps, + SkeletonTitleShape, +} from './skeleton-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { skeletonProps } from './skeleton-types' +import './skeleton.scss' + +function toSize(s: string | number): string { + return typeof s === 'number' ? `${s}px` : s +} + +export default defineComponent({ + name: 'CSkeleton', + props: skeletonProps, + setup(props: SkeletonProps, { slots }) { + const ns = useNamespace('skeleton') + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m('active')]: props.active, + [ns.m('round')]: props.round, + [ns.m('with-avatar')]: !!props.avatar, + })) + + const avatarStyle = computed(() => { + const av = props.avatar as SkeletonAvatarShape | boolean + if (!av || typeof av !== 'object') { + return {} + } + const sizeMap: Record = { small: 24, default: 32, large: 40 } + const size = typeof av.size === 'number' ? av.size : sizeMap[av.size ?? 'default'] + return { + width: `${size}px`, + height: `${size}px`, + borderRadius: av.shape === 'square' ? '4px' : '50%', + } + }) + + const titleStyle = computed(() => { + const t = props.title as SkeletonTitleShape | boolean + if (typeof t === 'object' && t.width !== undefined) { + return { width: toSize(t.width) } + } + return { width: '38%' } + }) + + const paragraphRows = computed(() => { + const p = props.paragraph as SkeletonParagraphShape | boolean + if (!p) { + return 0 + } + if (typeof p === 'object' && p.rows) { + return p.rows + } + return 3 + }) + + const paragraphRowStyle = (i: number, total: number): CSSProperties => { + const p = props.paragraph as SkeletonParagraphShape | boolean + if (typeof p === 'object' && p.width) { + if (Array.isArray(p.width)) { + return { width: p.width[i] !== undefined ? toSize(p.width[i]) : undefined } + } + if (i === total - 1) { + return { width: toSize(p.width) } + } + } + if (i === total - 1) { + return { width: '61%' } + } + return {} + } + + return () => { + if (!props.loading) { + return slots.default?.() + } + + const total = paragraphRows.value + + return ( +
+ {props.avatar && ( +
+ +
+ )} +
+ {props.title &&

} + {total > 0 && ( +
    + {Array.from({ length: total }).map((_, i) => ( +
  • + ))} +
+ )} +

+
+ ) + } + }, +}) diff --git a/packages/ccui/ui/skeleton/test/skeleton.test.ts b/packages/ccui/ui/skeleton/test/skeleton.test.ts new file mode 100644 index 00000000..e6001458 --- /dev/null +++ b/packages/ccui/ui/skeleton/test/skeleton.test.ts @@ -0,0 +1,35 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Skeleton } from '../index' + +const ns = useNamespace('skeleton', true) + +describe('skeleton', () => { + it('renders title and paragraph by default', () => { + const wrapper = mount(Skeleton) + expect(wrapper.find(ns.e('title')).exists()).toBe(true) + expect(wrapper.find(ns.e('paragraph')).exists()).toBe(true) + }) + + it('renders avatar when enabled', () => { + const wrapper = mount(Skeleton, { props: { avatar: true } }) + expect(wrapper.find(ns.e('avatar')).exists()).toBe(true) + }) + + it('shows children when loading is false', () => { + const wrapper = mount(Skeleton, { props: { loading: false }, slots: { default: '
x
' } }) + expect(wrapper.find('.real').exists()).toBe(true) + expect(wrapper.find(ns.b()).exists()).toBe(false) + }) + + it('active modifier toggles animation class', () => { + const wrapper = mount(Skeleton, { props: { active: true } }) + expect(wrapper.find(ns.m('active')).exists()).toBe(true) + }) + + it('respects paragraph rows config', () => { + const wrapper = mount(Skeleton, { props: { paragraph: { rows: 5 } } }) + expect(wrapper.findAll(`${ns.e('paragraph')} li`).length).toBe(5) + }) +}) diff --git a/packages/ccui/ui/space/index.ts b/packages/ccui/ui/space/index.ts new file mode 100644 index 00000000..a0d25432 --- /dev/null +++ b/packages/ccui/ui/space/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Space from './src/space' + +Space.install = function (app: App): void { + app.component(Space.name!, Space) +} + +export { Space } + +export default { + title: 'Space 间距', + category: '布局', + status: '100%', + install(app: App): void { + app.component(Space.name!, Space) + }, +} diff --git a/packages/ccui/ui/space/src/space-types.ts b/packages/ccui/ui/space/src/space-types.ts new file mode 100644 index 00000000..9b0fa582 --- /dev/null +++ b/packages/ccui/ui/space/src/space-types.ts @@ -0,0 +1,47 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type SpaceSize = 'small' | 'middle' | 'large' | number | [number, number] +export type SpaceDirection = 'horizontal' | 'vertical' +export type SpaceAlign = 'start' | 'end' | 'center' | 'baseline' + +export const spaceProps = { + align: { + type: String as PropType, + default: undefined, + }, + direction: { + type: String as PropType, + default: 'horizontal', + }, + size: { + type: [String, Number, Array] as PropType, + default: 'small', + }, + wrap: { + type: Boolean, + default: false, + }, + split: { + type: String, + default: '', + }, +} as const + +export type SpaceProps = ExtractPropTypes + +const SIZE_MAP: Record = { + small: 8, + middle: 16, + large: 24, +} + +export function resolveSize(size: SpaceSize): [number, number] { + if (Array.isArray(size)) { + return [size[0] ?? 0, size[1] ?? 0] + } + if (typeof size === 'number') { + return [size, size] + } + const v = SIZE_MAP[size] ?? 8 + return [v, v] +} diff --git a/packages/ccui/ui/space/src/space.scss b/packages/ccui/ui/space/src/space.scss new file mode 100644 index 00000000..a71f16c4 --- /dev/null +++ b/packages/ccui/ui/space/src/space.scss @@ -0,0 +1,43 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-space { + display: inline-flex; + + &--horizontal { + flex-direction: row; + } + + &--vertical { + flex-direction: column; + } + + &--align-start { + align-items: flex-start; + } + + &--align-end { + align-items: flex-end; + } + + &--align-center { + align-items: center; + } + + &--align-baseline { + align-items: baseline; + } + + &--wrap { + flex-wrap: wrap; + } + + &__item { + display: inline-flex; + } + + &__split { + display: inline-flex; + align-items: center; + color: $ccui-color-split; + } +} diff --git a/packages/ccui/ui/space/src/space.tsx b/packages/ccui/ui/space/src/space.tsx new file mode 100644 index 00000000..34fcca51 --- /dev/null +++ b/packages/ccui/ui/space/src/space.tsx @@ -0,0 +1,67 @@ +import type { CSSProperties, VNode } from 'vue' +import type { SpaceProps } from './space-types' +import { computed, defineComponent, Fragment } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { resolveSize, spaceProps } from './space-types' +import './space.scss' + +function flatChildren(nodes: VNode[]): VNode[] { + const list: VNode[] = [] + nodes.forEach((node) => { + if (node.type === Fragment && Array.isArray(node.children)) { + list.push(...flatChildren(node.children as VNode[])) + } + else if (node.type !== Comment) { + list.push(node) + } + }) + return list +} + +export default defineComponent({ + name: 'CSpace', + props: spaceProps, + setup(props: SpaceProps, { slots }) { + const ns = useNamespace('space') + + const gap = computed(() => resolveSize(props.size)) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.direction)]: true, + [ns.m(`align-${props.align}`)]: !!props.align, + [ns.m('wrap')]: props.wrap, + })) + + const style = computed(() => { + const [h, v] = gap.value + return { + columnGap: `${h}px`, + rowGap: `${v}px`, + } + }) + + return () => { + const children = flatChildren(slots.default?.() ?? []) + const splitContent = slots.split?.() ?? props.split + const total = children.length + + return ( +
+ {children.map((child, idx) => { + const item =
{child}
+ if (splitContent && idx < total - 1) { + return ( + + {item} + {splitContent} + + ) + } + return item + })} +
+ ) + } + }, +}) diff --git a/packages/ccui/ui/space/test/space.test.ts b/packages/ccui/ui/space/test/space.test.ts new file mode 100644 index 00000000..5bd227f3 --- /dev/null +++ b/packages/ccui/ui/space/test/space.test.ts @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Space } from '../index' + +const ns = useNamespace('space', true) + +describe('space', () => { + it('renders horizontal by default', () => { + const wrapper = mount(Space, { slots: { default: 'ab' } }) + expect(wrapper.find(ns.m('horizontal')).exists()).toBe(true) + }) + + it('renders vertical direction', () => { + const wrapper = mount(Space, { + props: { direction: 'vertical' }, + slots: { default: 'ab' }, + }) + expect(wrapper.find(ns.m('vertical')).exists()).toBe(true) + }) + + it('applies size as number', () => { + const wrapper = mount(Space, { props: { size: 20 }, slots: { default: 'a' } }) + const style = wrapper.find(ns.b()).attributes('style') ?? '' + expect(style).toContain('20px') + }) + + it('renders split slot between items', () => { + const wrapper = mount(Space, { + slots: { + default: 'abc', + split: '|', + }, + }) + const splits = wrapper.findAll(ns.e('split')) + expect(splits.length).toBe(2) + }) +}) diff --git a/packages/ccui/ui/spin/index.ts b/packages/ccui/ui/spin/index.ts new file mode 100644 index 00000000..ae637eb8 --- /dev/null +++ b/packages/ccui/ui/spin/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Spin from './src/spin' + +Spin.install = function (app: App): void { + app.component(Spin.name!, Spin) +} + +export { Spin } + +export default { + title: 'Spin 加载中', + category: '反馈', + status: '100%', + install(app: App): void { + app.component(Spin.name!, Spin) + }, +} diff --git a/packages/ccui/ui/spin/src/spin-types.ts b/packages/ccui/ui/spin/src/spin-types.ts new file mode 100644 index 00000000..593a84ce --- /dev/null +++ b/packages/ccui/ui/spin/src/spin-types.ts @@ -0,0 +1,32 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type SpinSize = 'small' | 'default' | 'large' + +export const spinProps = { + spinning: { + type: Boolean, + default: true, + }, + size: { + type: String as PropType, + default: 'default', + }, + tip: { + type: String, + default: '', + }, + delay: { + type: Number, + default: 0, + }, + fullscreen: { + type: Boolean, + default: false, + }, + percent: { + type: [Number, String] as PropType, + default: undefined, + }, +} as const + +export type SpinProps = ExtractPropTypes diff --git a/packages/ccui/ui/spin/src/spin.scss b/packages/ccui/ui/spin/src/spin.scss new file mode 100644 index 00000000..0cbae5ac --- /dev/null +++ b/packages/ccui/ui/spin/src/spin.scss @@ -0,0 +1,128 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-spin { + display: inline-flex; + flex-direction: column; + align-items: center; + color: $ccui-color-primary; + text-align: center; + vertical-align: middle; + + &__dot { + position: relative; + display: inline-block; + width: 20px; + height: 20px; + font-size: 20px; + transform: rotate(45deg); + animation: ccuiSpinRotate 1.2s infinite linear; + + &--small { + width: 14px; + height: 14px; + font-size: 14px; + } + + &--large { + width: 32px; + height: 32px; + font-size: 32px; + } + + &-item { + position: absolute; + display: block; + width: 9px; + height: 9px; + background-color: $ccui-color-primary; + border-radius: 100%; + opacity: 0.3; + transform: scale(0.75); + transform-origin: 50% 50%; + animation: ccuiSpinAnim 1s infinite linear alternate; + + &--1 { + top: 0; + inset-inline-start: 0; + } + + &--2 { + top: 0; + inset-inline-end: 0; + animation-delay: 0.4s; + } + + &--3 { + inset-inline-end: 0; + bottom: 0; + animation-delay: 0.8s; + } + + &--4 { + bottom: 0; + inset-inline-start: 0; + animation-delay: 1.2s; + } + } + } + + &--small &__dot-item { + width: 6px; + height: 6px; + } + + &--large &__dot-item { + width: 14px; + height: 14px; + } + + &__text { + margin-top: 8px; + font-size: $ccui-font-size; + color: $ccui-color-primary; + } + + &--fullscreen { + position: fixed; + inset: 0; + z-index: $ccui-z-index-modal; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.45); + color: #ffffff; + } + + &__nested { + position: relative; + } + + &__overlay { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + &__container.is-blur { + opacity: 0.5; + pointer-events: none; + user-select: none; + } +} + +@keyframes ccuiSpinAnim { + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes ccuiSpinRotate { + to { + transform: rotate(405deg); + } +} diff --git a/packages/ccui/ui/spin/src/spin.tsx b/packages/ccui/ui/spin/src/spin.tsx new file mode 100644 index 00000000..370eef76 --- /dev/null +++ b/packages/ccui/ui/spin/src/spin.tsx @@ -0,0 +1,97 @@ +import type { SpinProps } from './spin-types' +import { computed, defineComponent, onBeforeUnmount, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { spinProps } from './spin-types' +import './spin.scss' + +export default defineComponent({ + name: 'CSpin', + props: spinProps, + setup(props: SpinProps, { slots }) { + const ns = useNamespace('spin') + const visible = ref(props.spinning) + let timer: ReturnType | null = null + + const clearDelay = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + watch( + () => props.spinning, + (val) => { + clearDelay() + if (val && props.delay > 0) { + timer = setTimeout(() => { + visible.value = true + }, props.delay) + visible.value = false + } + else { + visible.value = val + } + }, + { immediate: true }, + ) + + onBeforeUnmount(clearDelay) + + const indicatorCls = computed(() => ({ + [ns.e('dot')]: true, + [ns.em('dot', props.size)]: true, + })) + + const renderIndicator = () => { + if (slots.indicator) { + return slots.indicator() + } + return ( + + + + + + + ) + } + + const renderSpinner = () => ( +
+ {renderIndicator()} + {props.tip &&
{props.tip}
} +
+ ) + + return () => { + if (props.fullscreen) { + if (!visible.value) { + return null + } + return ( +
+ {renderSpinner()} +
+ ) + } + + if (!slots.default) { + return visible.value ? renderSpinner() : null + } + + return ( +
+ {visible.value && ( +
+ {renderSpinner()} +
+ )} +
+ {slots.default()} +
+
+ ) + } + }, +}) diff --git a/packages/ccui/ui/spin/test/spin.test.ts b/packages/ccui/ui/spin/test/spin.test.ts new file mode 100644 index 00000000..9b041e6d --- /dev/null +++ b/packages/ccui/ui/spin/test/spin.test.ts @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Spin } from '../index' + +const ns = useNamespace('spin', true) + +describe('spin', () => { + it('renders spinner standalone', () => { + const wrapper = mount(Spin) + expect(wrapper.find(ns.b()).exists()).toBe(true) + expect(wrapper.find(ns.e('dot')).exists()).toBe(true) + }) + + it('renders tip text', () => { + const wrapper = mount(Spin, { props: { tip: 'Loading...' } }) + expect(wrapper.text()).toContain('Loading...') + }) + + it('hides spinner when spinning false', () => { + const wrapper = mount(Spin, { props: { spinning: false } }) + expect(wrapper.find(ns.b()).exists()).toBe(false) + }) + + it('wraps default slot', () => { + const wrapper = mount(Spin, { slots: { default: '
Hi
' } }) + expect(wrapper.find('.content').exists()).toBe(true) + expect(wrapper.find(ns.e('overlay')).exists()).toBe(true) + }) +}) diff --git a/packages/ccui/ui/status/src/status.scss b/packages/ccui/ui/status/src/status.scss index 147f0e0b..ca78a4e3 100644 --- a/packages/ccui/ui/status/src/status.scss +++ b/packages/ccui/ui/status/src/status.scss @@ -1,46 +1,49 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-status { - &:before { + display: inline-flex; + align-items: center; + font-size: $ccui-font-size; + color: $ccui-color-text; + + &::before { display: inline-block; content: ''; - width: 10px; - height: 10px; - margin-right: 5px; - border-radius: 100%; + width: 6px; + height: 6px; + margin-right: 8px; + border-radius: 50%; } span { - margin-left: 5px; + margin-left: 0; } -} -.#{$cls-prefix}-status { - &--invalid:before { - background: #2c3e50; + &--invalid::before { + background: $ccui-color-text-disabled; } - &--error:before { - background: red; + &--error::before { + background: $ccui-color-error; } - &--initial:before { - background: #e9edfa; + &--initial::before { + background: $ccui-color-fill; } - &--warning:before { - background: #fac20a; + &--warning::before { + background: $ccui-color-warning; } - &--waiting:before { - background: #beccfa; + &--waiting::before { + background: $ccui-color-text-tertiary; } - &--running:before { - background: #5e7ce0; + &--running::before { + background: $ccui-color-primary; } - &--success:before { - background: #50d4ab; + &--success::before { + background: $ccui-color-success; } } diff --git a/packages/ccui/ui/switch/index.ts b/packages/ccui/ui/switch/index.ts new file mode 100644 index 00000000..c3ad3b78 --- /dev/null +++ b/packages/ccui/ui/switch/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Switch from './src/switch' + +Switch.install = function (app: App): void { + app.component(Switch.name!, Switch) +} + +export { Switch } + +export default { + title: 'Switch 开关', + category: '数据录入', + status: '100%', + install(app: App): void { + app.component(Switch.name!, Switch) + }, +} diff --git a/packages/ccui/ui/switch/src/switch-types.ts b/packages/ccui/ui/switch/src/switch-types.ts new file mode 100644 index 00000000..8d3b26b7 --- /dev/null +++ b/packages/ccui/ui/switch/src/switch-types.ts @@ -0,0 +1,45 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type SwitchSize = 'default' | 'small' +export type SwitchValue = boolean | string | number + +export const switchProps = { + modelValue: { + type: [Boolean, String, Number] as PropType, + default: false, + }, + checkedValue: { + type: [Boolean, String, Number] as PropType, + default: true, + }, + uncheckedValue: { + type: [Boolean, String, Number] as PropType, + default: false, + }, + disabled: { + type: Boolean, + default: false, + }, + loading: { + type: Boolean, + default: false, + }, + size: { + type: String as PropType, + default: 'default', + }, + checkedChildren: { + type: String, + default: '', + }, + uncheckedChildren: { + type: String, + default: '', + }, + autofocus: { + type: Boolean, + default: false, + }, +} as const + +export type SwitchProps = ExtractPropTypes diff --git a/packages/ccui/ui/switch/src/switch.scss b/packages/ccui/ui/switch/src/switch.scss new file mode 100644 index 00000000..201a2a7a --- /dev/null +++ b/packages/ccui/ui/switch/src/switch.scss @@ -0,0 +1,122 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-switch { + position: relative; + display: inline-flex; + align-items: center; + box-sizing: border-box; + min-width: 44px; + height: 22px; + padding: 0; + color: #ffffff; + font-size: $ccui-font-size; + line-height: 22px; + vertical-align: middle; + background: rgba(0, 0, 0, 0.45); + border: 0; + border-radius: 100px; + cursor: pointer; + transition: all $ccui-motion-duration-mid; + user-select: none; + + &:focus-visible { + outline: 2px solid $ccui-color-primary-border; + outline-offset: 1px; + } + + &__handle { + position: absolute; + top: 2px; + inset-inline-start: 2px; + width: 18px; + height: 18px; + background: #ffffff; + border-radius: 50%; + box-shadow: 0 2px 4px 0 rgba(0, 35, 11, 0.2); + transition: all $ccui-motion-duration-mid ease-in-out; + } + + &__inner { + display: block; + margin: 0 7px 0 25px; + color: #ffffff; + font-size: $ccui-font-size-sm; + transition: margin $ccui-motion-duration-mid; + + span { + display: inline-block; + line-height: 1; + } + } + + &--checked { + background: $ccui-color-primary; + + .#{$cls-prefix}-switch__handle { + inset-inline-start: calc(100% - 20px); + } + + .#{$cls-prefix}-switch__inner { + margin: 0 25px 0 7px; + } + } + + &--disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &--loading { + cursor: default; + + .#{$cls-prefix}-switch__loading { + position: absolute; + top: 4px; + inset-inline-start: 6px; + width: 14px; + height: 14px; + border: 2px solid $ccui-color-primary; + border-top-color: transparent; + border-radius: 50%; + animation: ccuiSwitchSpin 1s linear infinite; + } + } + + &--checked.#{$cls-prefix}-switch--loading .#{$cls-prefix}-switch__loading { + inset-inline-start: calc(100% - 18px); + border-color: #ffffff; + border-top-color: transparent; + } + + &--small { + min-width: 28px; + height: 16px; + line-height: 16px; + + .#{$cls-prefix}-switch__handle { + width: 12px; + height: 12px; + } + + .#{$cls-prefix}-switch__inner { + margin: 0 5px 0 18px; + font-size: 12px; + } + + &.#{$cls-prefix}-switch--checked { + .#{$cls-prefix}-switch__handle { + inset-inline-start: calc(100% - 14px); + } + + .#{$cls-prefix}-switch__inner { + margin: 0 18px 0 5px; + } + } + } +} + +@keyframes ccuiSwitchSpin { + to { + transform: rotate(360deg); + } +} diff --git a/packages/ccui/ui/switch/src/switch.tsx b/packages/ccui/ui/switch/src/switch.tsx new file mode 100644 index 00000000..f401c2bd --- /dev/null +++ b/packages/ccui/ui/switch/src/switch.tsx @@ -0,0 +1,56 @@ +import type { SwitchProps } from './switch-types' +import { computed, defineComponent, ref } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { switchProps } from './switch-types' +import './switch.scss' + +export default defineComponent({ + name: 'CSwitch', + props: switchProps, + emits: ['update:modelValue', 'change', 'click'], + setup(props: SwitchProps, { slots, emit }) { + const ns = useNamespace('switch') + const inputRef = ref(null) + + const checked = computed(() => props.modelValue === props.checkedValue) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m('checked')]: checked.value, + [ns.m('disabled')]: props.disabled, + [ns.m('loading')]: props.loading, + [ns.m(props.size)]: props.size === 'small', + })) + + const toggle = (e: MouseEvent) => { + if (props.disabled || props.loading) { + return + } + const next = checked.value ? props.uncheckedValue : props.checkedValue + emit('update:modelValue', next) + emit('change', next, e) + emit('click', e) + } + + return () => ( + + ) + }, +}) diff --git a/packages/ccui/ui/switch/test/switch.test.ts b/packages/ccui/ui/switch/test/switch.test.ts new file mode 100644 index 00000000..2a8e630f --- /dev/null +++ b/packages/ccui/ui/switch/test/switch.test.ts @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Switch } from '../index' + +const ns = useNamespace('switch', true) + +describe('switch', () => { + it('toggles modelValue on click', async () => { + const wrapper = mount(Switch, { props: { modelValue: false } }) + await wrapper.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[true]]) + }) + + it('reflects checked modifier', () => { + const wrapper = mount(Switch, { props: { modelValue: true } }) + expect(wrapper.find(ns.m('checked')).exists()).toBe(true) + }) + + it('does not trigger when disabled', async () => { + const wrapper = mount(Switch, { props: { modelValue: false, disabled: true } }) + await wrapper.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('renders checked children slot', () => { + const wrapper = mount(Switch, { + props: { modelValue: true }, + slots: { checkedChildren: 'ON' }, + }) + expect(wrapper.text()).toContain('ON') + }) + + it('supports custom checked/unchecked values', async () => { + const wrapper = mount(Switch, { + props: { modelValue: 'no', checkedValue: 'yes', uncheckedValue: 'no' }, + }) + await wrapper.trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['yes']) + }) +}) diff --git a/packages/ccui/ui/tag/index.ts b/packages/ccui/ui/tag/index.ts new file mode 100644 index 00000000..dc97f81a --- /dev/null +++ b/packages/ccui/ui/tag/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Tag from './src/tag' + +Tag.install = function (app: App): void { + app.component(Tag.name!, Tag) +} + +export { Tag } + +export default { + title: 'Tag 标签', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(Tag.name!, Tag) + }, +} diff --git a/packages/ccui/ui/tag/src/tag-types.ts b/packages/ccui/ui/tag/src/tag-types.ts new file mode 100644 index 00000000..3f715b11 --- /dev/null +++ b/packages/ccui/ui/tag/src/tag-types.ts @@ -0,0 +1,65 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type TagColor + = | 'default' + | 'primary' + | 'success' + | 'processing' + | 'error' + | 'warning' + | 'magenta' + | 'red' + | 'volcano' + | 'orange' + | 'gold' + | 'lime' + | 'green' + | 'cyan' + | 'blue' + | 'geekblue' + | 'purple' + +export const tagProps = { + color: { + type: String as PropType, + default: 'default', + }, + bordered: { + type: Boolean, + default: true, + }, + closable: { + type: Boolean, + default: false, + }, + icon: { + type: String, + default: '', + }, +} as const + +export type TagProps = ExtractPropTypes + +const PRESET_COLORS = new Set([ + 'default', + 'primary', + 'success', + 'processing', + 'error', + 'warning', + 'magenta', + 'red', + 'volcano', + 'orange', + 'gold', + 'lime', + 'green', + 'cyan', + 'blue', + 'geekblue', + 'purple', +]) + +export function isPresetColor(color: string): boolean { + return PRESET_COLORS.has(color) +} diff --git a/packages/ccui/ui/tag/src/tag.scss b/packages/ccui/ui/tag/src/tag.scss new file mode 100644 index 00000000..213ecfc4 --- /dev/null +++ b/packages/ccui/ui/tag/src/tag.scss @@ -0,0 +1,122 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-tag { + display: inline-flex; + align-items: center; + height: auto; + padding-inline: 7px; + font-size: $ccui-font-size-sm; + line-height: 20px; + white-space: nowrap; + background: $ccui-color-fill-quaternary; + border: 1px solid $ccui-color-border; + border-radius: $ccui-border-radius-sm; + opacity: 1; + transition: all $ccui-motion-duration-mid; + color: $ccui-color-text; + + &__icon { + display: inline-flex; + align-items: center; + margin-inline-end: 4px; + + svg, + i { + width: 12px; + height: 12px; + } + } + + &__content { + display: inline-flex; + align-items: center; + } + + &__close { + display: inline-flex; + align-items: center; + justify-content: center; + margin-inline-start: 6px; + color: rgba(0, 0, 0, 0.45); + font-size: 10px; + cursor: pointer; + transition: color $ccui-motion-duration-mid; + + &:hover { + color: $ccui-color-text; + } + } + + &--borderless { + border-color: transparent; + } + + &--has-color { + color: $ccui-color-text-light-solid; + border-color: transparent; + + .#{$cls-prefix}-tag__close { + color: rgba(255, 255, 255, 0.85); + + &:hover { + color: #ffffff; + } + } + } + + // 状态色 + &--default { + background: $ccui-color-fill-quaternary; + border-color: $ccui-color-border; + color: $ccui-color-text; + } + + &--primary, + &--processing { + background: $ccui-color-primary-bg; + border-color: $ccui-color-primary-border; + color: $ccui-color-primary; + } + + &--success { + background: $ccui-color-success-bg; + border-color: $ccui-color-success-border; + color: $ccui-color-success; + } + + &--warning { + background: $ccui-color-warning-bg; + border-color: $ccui-color-warning-border; + color: $ccui-color-warning; + } + + &--error { + background: $ccui-color-error-bg; + border-color: $ccui-color-error-border; + color: $ccui-color-error; + } + + // 预设色板 + @each $name, $bg, $border, + $text + in ( + (magenta, #fff0f6, #ffadd2, #c41d7f), + (red, #fff1f0, #ffa39e, #cf1322), + (volcano, #fff2e8, #ffbb96, #d4380d), + (orange, #fff7e6, #ffd591, #d46b08), + (gold, #fffbe6, #ffe58f, #d48806), + (lime, #fcffe6, #eaff8f, #7cb305), + (green, #f6ffed, #b7eb8f, #389e0d), + (cyan, #e6fffb, #87e8de, #08979c), + (blue, #e6f4ff, #91caff, #0958d9), + (geekblue, #f0f5ff, #adc6ff, #1d39c4), + (purple, #f9f0ff, #d3adf7, #531dab) + ) + { + &--#{$name} { + background: $bg; + border-color: $border; + color: $text; + } + } +} diff --git a/packages/ccui/ui/tag/src/tag.tsx b/packages/ccui/ui/tag/src/tag.tsx new file mode 100644 index 00000000..9fea810d --- /dev/null +++ b/packages/ccui/ui/tag/src/tag.tsx @@ -0,0 +1,52 @@ +import type { TagProps } from './tag-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { isPresetColor, tagProps } from './tag-types' +import './tag.scss' + +export default defineComponent({ + name: 'CTag', + props: tagProps, + emits: ['close'], + setup(props: TagProps, { slots, emit }) { + const ns = useNamespace('tag') + + const isPreset = computed(() => isPresetColor(props.color)) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.color)]: isPreset.value, + [ns.m('borderless')]: !props.bordered, + [ns.m('has-color')]: !isPreset.value && !!props.color, + })) + + const style = computed(() => { + if (isPreset.value || !props.color) { + return undefined + } + return { + backgroundColor: props.color, + borderColor: props.color, + } + }) + + const onClose = (e: MouseEvent) => { + e.stopPropagation() + emit('close', e) + } + + return () => ( + + {slots.icon ? {slots.icon()} : props.icon && } + {slots.default?.()} + {props.closable && ( + + + + )} + + ) + }, +}) diff --git a/packages/ccui/ui/tag/test/tag.test.ts b/packages/ccui/ui/tag/test/tag.test.ts new file mode 100644 index 00000000..81048a5f --- /dev/null +++ b/packages/ccui/ui/tag/test/tag.test.ts @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Tag } from '../index' + +const ns = useNamespace('tag', true) + +describe('tag', () => { + it('renders default content', () => { + const wrapper = mount(Tag, { slots: { default: 'tag1' } }) + expect(wrapper.find(ns.b()).exists()).toBe(true) + expect(wrapper.text()).toContain('tag1') + }) + + it('applies preset color modifier', () => { + const wrapper = mount(Tag, { props: { color: 'success' }, slots: { default: 'ok' } }) + expect(wrapper.find(ns.m('success')).exists()).toBe(true) + }) + + it('applies inline style for custom color', () => { + const wrapper = mount(Tag, { props: { color: '#ff7875' }, slots: { default: 'x' } }) + const style = wrapper.find(ns.b()).attributes('style') ?? '' + expect(style).toContain('background-color') + }) + + it('emits close event when closable', async () => { + const wrapper = mount(Tag, { props: { closable: true }, slots: { default: 'x' } }) + await wrapper.find(ns.e('close')).trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('borderless modifier', () => { + const wrapper = mount(Tag, { props: { bordered: false }, slots: { default: 'x' } }) + expect(wrapper.find(ns.m('borderless')).exists()).toBe(true) + }) +}) diff --git a/packages/ccui/ui/typography/index.ts b/packages/ccui/ui/typography/index.ts new file mode 100644 index 00000000..4c8355e5 --- /dev/null +++ b/packages/ccui/ui/typography/index.ts @@ -0,0 +1,19 @@ +import type { App } from 'vue' +import { Link, Paragraph, Text, Title, Typography } from './src/typography' + +const components = [Typography, Text, Paragraph, Title, Link] + +;(Typography as any).install = function (app: App): void { + components.forEach(c => app.component((c as any).name, c)) +} + +export { Link, Paragraph, Text, Title, Typography } + +export default { + title: 'Typography 排版', + category: '通用', + status: '100%', + install(app: App): void { + components.forEach(c => app.component((c as any).name, c)) + }, +} diff --git a/packages/ccui/ui/typography/src/typography-types.ts b/packages/ccui/ui/typography/src/typography-types.ts new file mode 100644 index 00000000..c09d90b7 --- /dev/null +++ b/packages/ccui/ui/typography/src/typography-types.ts @@ -0,0 +1,72 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type TypographyType = 'secondary' | 'success' | 'warning' | 'danger' +export type TitleLevel = 1 | 2 | 3 | 4 | 5 + +export const baseTextProps = { + type: { + type: String as PropType, + default: undefined, + }, + disabled: { + type: Boolean, + default: false, + }, + mark: { + type: Boolean, + default: false, + }, + code: { + type: Boolean, + default: false, + }, + keyboard: { + type: Boolean, + default: false, + }, + underline: { + type: Boolean, + default: false, + }, + delete: { + type: Boolean, + default: false, + }, + strong: { + type: Boolean, + default: false, + }, + italic: { + type: Boolean, + default: false, + }, +} as const + +export const textProps = baseTextProps +export const paragraphProps = baseTextProps + +export const titleProps = { + ...baseTextProps, + level: { + type: Number as PropType, + default: 1, + validator: (v: number) => v >= 1 && v <= 5, + }, +} as const + +export const linkProps = { + ...baseTextProps, + href: { + type: String, + default: undefined, + }, + target: { + type: String, + default: undefined, + }, +} as const + +export type TextProps = ExtractPropTypes +export type ParagraphProps = ExtractPropTypes +export type TitleProps = ExtractPropTypes +export type LinkProps = ExtractPropTypes diff --git a/packages/ccui/ui/typography/src/typography.scss b/packages/ccui/ui/typography/src/typography.scss new file mode 100644 index 00000000..01cb9564 --- /dev/null +++ b/packages/ccui/ui/typography/src/typography.scss @@ -0,0 +1,113 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-typography { + color: $ccui-color-text; + word-break: break-word; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; + + &--secondary { + color: $ccui-color-text-secondary; + } + + &--success { + color: $ccui-color-success; + } + + &--warning { + color: $ccui-color-warning; + } + + &--danger { + color: $ccui-color-error; + } + + &--disabled { + color: $ccui-color-text-disabled; + cursor: not-allowed; + user-select: none; + } + + &--text { + font-size: inherit; + } + + &--paragraph { + margin-bottom: 1em; + } + + &--title { + margin-top: 1.2em; + margin-bottom: 0.5em; + color: $ccui-color-text-heading; + font-weight: 600; + } + + &--title-1 { + margin-top: 0; + font-size: $ccui-font-size-heading-1; + line-height: $ccui-line-height-heading-1; + } + + &--title-2 { + font-size: $ccui-font-size-heading-2; + line-height: $ccui-line-height-heading-2; + } + + &--title-3 { + font-size: $ccui-font-size-heading-3; + line-height: $ccui-line-height-heading-3; + } + + &--title-4 { + font-size: $ccui-font-size-heading-4; + line-height: $ccui-line-height-heading-4; + } + + &--title-5 { + font-size: $ccui-font-size-heading-5; + line-height: $ccui-line-height-heading-5; + } + + &--link { + color: $ccui-color-primary; + text-decoration: none; + cursor: pointer; + transition: color $ccui-motion-duration-mid; + + &:hover { + color: $ccui-color-primary-hover; + text-decoration: underline; + } + + &:active { + color: $ccui-color-primary-active; + } + } + + code { + margin: 0 0.2em; + padding: 0.2em 0.4em; + font-size: 85%; + background: rgba(150, 150, 150, 0.1); + border: 1px solid rgba(100, 100, 100, 0.2); + border-radius: $ccui-border-radius-sm; + font-family: $ccui-font-family-code; + } + + kbd { + margin: 0 0.2em; + padding: 0.15em 0.4em 0.1em; + font-size: 90%; + background: rgba(150, 150, 150, 0.06); + border: 1px solid rgba(100, 100, 100, 0.2); + border-bottom-width: 2px; + border-radius: $ccui-border-radius-sm; + font-family: $ccui-font-family-code; + } + + mark { + background-color: #ffe58f; + padding: 0; + } +} diff --git a/packages/ccui/ui/typography/src/typography.tsx b/packages/ccui/ui/typography/src/typography.tsx new file mode 100644 index 00000000..7fed37e4 --- /dev/null +++ b/packages/ccui/ui/typography/src/typography.tsx @@ -0,0 +1,121 @@ +import type { Slots, VNode } from 'vue' +import type { LinkProps, TextProps, TitleProps } from './typography-types' +import { computed, defineComponent, h } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { linkProps, paragraphProps, textProps, titleProps } from './typography-types' +import './typography.scss' + +const ns = useNamespace('typography') + +function buildModifierClasses(props: TextProps) { + return { + [ns.b()]: true, + [ns.m(props.type as string)]: !!props.type, + [ns.m('disabled')]: props.disabled, + } +} + +function wrapWithDecorations(node: VNode | string, props: TextProps): VNode | string { + let result: VNode | string = node + if (props.code) { + result = h('code', null, [result]) + } + if (props.keyboard) { + result = h('kbd', null, [result]) + } + if (props.mark) { + result = h('mark', null, [result]) + } + if (props.underline) { + result = h('u', null, [result]) + } + if (props.delete) { + result = h('del', null, [result]) + } + if (props.strong) { + result = h('strong', null, [result]) + } + if (props.italic) { + result = h('i', null, [result]) + } + return result +} + +function renderInner(slots: Slots, props: TextProps): VNode[] | VNode | string { + const children = slots.default?.() ?? [] + if (children.length === 0) { + return '' + } + if (children.length === 1) { + return wrapWithDecorations(children[0], props) as VNode | string + } + return children.map(c => wrapWithDecorations(c, props)) as VNode[] +} + +export const Text = defineComponent({ + name: 'CTypographyText', + props: textProps, + setup(props, { slots }) { + const cls = computed(() => ({ + ...buildModifierClasses(props as TextProps), + [ns.m('text')]: true, + })) + return () => h('span', { class: cls.value }, renderInner(slots, props as TextProps)) + }, +}) + +export const Paragraph = defineComponent({ + name: 'CTypographyParagraph', + props: paragraphProps, + setup(props, { slots }) { + const cls = computed(() => ({ + ...buildModifierClasses(props as TextProps), + [ns.m('paragraph')]: true, + })) + return () => h('div', { class: cls.value }, renderInner(slots, props as TextProps)) + }, +}) + +export const Title = defineComponent({ + name: 'CTypographyTitle', + props: titleProps, + setup(props, { slots }) { + const cls = computed(() => ({ + ...buildModifierClasses(props as unknown as TextProps), + [ns.m('title')]: true, + [ns.m(`title-${props.level}`)]: true, + })) + return () => h( + `h${(props as TitleProps).level}`, + { class: cls.value }, + renderInner(slots, props as unknown as TextProps), + ) + }, +}) + +export const Link = defineComponent({ + name: 'CTypographyLink', + props: linkProps, + setup(props, { slots }) { + const cls = computed(() => ({ + ...buildModifierClasses(props as TextProps), + [ns.m('link')]: true, + })) + return () => h( + 'a', + { + class: cls.value, + href: (props as LinkProps).href, + target: (props as LinkProps).target, + }, + renderInner(slots, props as TextProps), + ) + }, +}) + +export const Typography = defineComponent({ + name: 'CTypography', + setup(_, { slots }) { + return () => h('article', { class: ns.b() }, slots.default?.()) + }, +}) diff --git a/packages/ccui/ui/typography/test/typography.test.ts b/packages/ccui/ui/typography/test/typography.test.ts new file mode 100644 index 00000000..3f9e6e0d --- /dev/null +++ b/packages/ccui/ui/typography/test/typography.test.ts @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Link, Paragraph, Text, Title } from '../index' + +const ns = useNamespace('typography', true) + +describe('typography', () => { + it('text renders span with type modifier', () => { + const wrapper = mount(Text, { props: { type: 'success' }, slots: { default: 'ok' } }) + expect(wrapper.element.tagName).toBe('SPAN') + expect(wrapper.find(ns.m('success')).exists()).toBe(true) + }) + + it('paragraph renders div', () => { + const wrapper = mount(Paragraph, { slots: { default: 'Hello' } }) + expect(wrapper.element.tagName).toBe('DIV') + expect(wrapper.find(ns.m('paragraph')).exists()).toBe(true) + }) + + it('title renders h tag based on level', () => { + const wrapper = mount(Title, { props: { level: 3 }, slots: { default: 'Title' } }) + expect(wrapper.element.tagName).toBe('H3') + expect(wrapper.find(ns.m('title-3')).exists()).toBe(true) + }) + + it('link renders anchor with href', () => { + const wrapper = mount(Link, { props: { href: '/x' }, slots: { default: 'go' } }) + expect(wrapper.element.tagName).toBe('A') + expect(wrapper.attributes('href')).toBe('/x') + }) + + it('text wraps decorations like strong + code', () => { + const wrapper = mount(Text, { props: { strong: true, code: true }, slots: { default: 'x' } }) + expect(wrapper.find('strong').exists()).toBe(true) + expect(wrapper.find('code').exists()).toBe(true) + }) +}) diff --git a/packages/docs/components/alert/index.md b/packages/docs/components/alert/index.md new file mode 100644 index 00000000..9767c3f6 --- /dev/null +++ b/packages/docs/components/alert/index.md @@ -0,0 +1,111 @@ +# Alert 警告提示 + +警告提示,展现需要关注的信息。 + +## 何时使用 + +- 当某个页面需要向用户显示警告的信息时。 +- 非浮层的静态展现形式,始终展现,不会自动消失,用户可以点击关闭。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 含有辅助性文字介绍 + +:::demo + +```vue + +``` + +::: + +## 显示图标 + +:::demo + +```vue + +``` + +::: + +## 可关闭 + +:::demo + +```vue + + + +``` + +::: + +## Alert 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ---------------------------------------- | ------ | ------------------ | +| type | 'success' / 'info' / 'warning' / 'error' | 'info' | 类型 | +| message | string | -- | 警告主标题 | +| description | string | -- | 辅助性文字描述 | +| showIcon | boolean | false | 是否显示图标 | +| closable | boolean | false | 默认显示关闭按钮 | +| closeText | string | -- | 自定义关闭按钮文本 | +| banner | boolean | false | 是否用作顶部公告 | + +## Alert 事件 + +| 事件名 | 参数 | 说明 | +| ------ | ---- | -------------- | +| close | -- | 关闭按钮被点击 | diff --git a/packages/docs/components/badge/index.md b/packages/docs/components/badge/index.md new file mode 100644 index 00000000..5292d2c6 --- /dev/null +++ b/packages/docs/components/badge/index.md @@ -0,0 +1,120 @@ +# Badge 徽标数 + +图标右上角的圆形徽标数字。 + +## 何时使用 + +- 一般出现在通知图标或头像的右上角,用于显示需要处理的消息条数。 +- 用于其他需要展示状态的场景。 + +## 基本使用 + +:::demo + +```vue + + + +``` + +::: + +## 独立使用 + +:::demo + +```vue + +``` + +::: + +## 小红点 + +:::demo + +```vue + + + +``` + +::: + +## 状态点 + +:::demo + +```vue + +``` + +::: + +## Badge 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ------------- | ---------------------------------------------------------- | ------ | -------------------------- | +| count | number / string | -- | 展示数字,0 默认隐藏 | +| showZero | boolean | false | 当数值为 0 时是否展示 | +| overflowCount | number | 99 | 展示封顶的数字值 | +| dot | boolean | false | 不展示数字,只有一个小红点 | +| status | 'success' / 'processing' / 'default' / 'error' / 'warning' | -- | 状态点的颜色 | +| text | string | -- | 状态点的文字 | +| color | string | -- | 自定义小圆点颜色 | +| offset | [number, number] | -- | 设置徽标数偏移 | diff --git a/packages/docs/components/breadcrumb/index.md b/packages/docs/components/breadcrumb/index.md new file mode 100644 index 00000000..58c7f4b6 --- /dev/null +++ b/packages/docs/components/breadcrumb/index.md @@ -0,0 +1,81 @@ +# Breadcrumb 面包屑 + +显示当前页面在系统层级结构中的位置,并能向上返回。 + +## 何时使用 + +- 当系统拥有超过两级以上的层级结构时。 +- 需要告知用户当前位置时。 +- 需要向上导航的功能时。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 通过 routes 配置 + +:::demo + +```vue + + + +``` + +::: + +## Breadcrumb 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | ----------------- | ------ | -------- | +| separator | string | '/' | 分隔符 | +| routes | BreadcrumbRoute[] | [] | 路由数据 | + +## BreadcrumbItem 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | ------ | ------ | ------------ | +| href | string | -- | 链接地址 | +| separator | string | -- | 自定义分隔符 | + +## BreadcrumbItem 插槽 + +| 插槽名 | 说明 | +| --------- | ------------ | +| default | 内容 | +| separator | 自定义分隔符 | diff --git a/packages/docs/components/empty/index.md b/packages/docs/components/empty/index.md new file mode 100644 index 00000000..b580a5d7 --- /dev/null +++ b/packages/docs/components/empty/index.md @@ -0,0 +1,52 @@ +# Empty 空状态 + +空状态时的展示占位。 + +## 何时使用 + +- 当目前没有数据时,用于显式的用户提示。 +- 初始化场景时的引导创建过程。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 自定义描述与额外内容 + +:::demo + +```vue + +``` + +::: + +## Empty 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ------ | ---------- | -------------- | +| description | string | '暂无数据' | 描述文案 | +| image | string | -- | 自定义图片地址 | +| imageStyle | object | -- | 图片样式 | + +## Empty 插槽 + +| 插槽名 | 说明 | +| ----------- | -------------- | +| default | 自定义底部内容 | +| description | 自定义描述内容 | +| image | 自定义图片内容 | diff --git a/packages/docs/components/flex/index.md b/packages/docs/components/flex/index.md new file mode 100644 index 00000000..2383d24a --- /dev/null +++ b/packages/docs/components/flex/index.md @@ -0,0 +1,67 @@ +# Flex 弹性布局 + +弹性布局组件,用于灵活控制内部内容的排列方式。 + +## 何时使用 + +- 适合行内或者垂直布局的简单场景。 +- 子元素需要紧贴左右两端、自适应宽度时。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 垂直布局 + +:::demo + +```vue + +``` + +::: + +## 对齐方式 + +:::demo + +```vue + +``` + +::: + +## Flex 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | ------------------ | -------- | ----------------------------- | +| vertical | boolean | false | 是否垂直布局 | +| wrap | boolean / FlexWrap | false | 是否换行 | +| justify | string | 'normal' | justify-content | +| align | string | 'normal' | align-items | +| flex | string | 'normal' | flex 简写 | +| gap | string / number | -- | small / middle / large / 数字 | +| component | string | 'div' | 自定义元素标签 | diff --git a/packages/docs/components/input/index.md b/packages/docs/components/input/index.md index a329e945..7809c90c 100644 --- a/packages/docs/components/input/index.md +++ b/packages/docs/components/input/index.md @@ -255,14 +255,14 @@ export type InputSize = 'large' | 'default' | 'small' ## Input事件 -| 事件名 | 参数 | 说明 | -| -------------- | ----- | -------------------------------- | +| 事件名 | 参数 | 说明 | +| ----------------- | ----- | -------------------------------- | | update:modelValue | value | 绑定值改变时触发(v-model 事件) | -| input | value | 输入框值改变时触发 | -| change | value | 输入框值改变并失去焦点时触发 | -| focus | event | 输入框获得焦点时触发 | -| blur | event | 输入框失去焦点时触发 | -| clear | -- | 点击清空图标时触发 | +| input | value | 输入框值改变时触发 | +| change | value | 输入框值改变并失去焦点时触发 | +| focus | event | 输入框获得焦点时触发 | +| blur | event | 输入框失去焦点时触发 | +| clear | -- | 点击清空图标时触发 | ## Input插槽 diff --git a/packages/docs/components/progress/index.md b/packages/docs/components/progress/index.md new file mode 100644 index 00000000..c8ab5116 --- /dev/null +++ b/packages/docs/components/progress/index.md @@ -0,0 +1,64 @@ +# Progress 进度条 + +展示操作的当前进度。 + +## 何时使用 + +- 当一个操作会打断当前界面,或者需要在后台运行,且耗时可能超过 2 秒时。 +- 当需要显示一个操作完成的百分比时。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 圆形进度 + +:::demo + +```vue + +``` + +::: + +## 仪表盘 + +:::demo + +```vue + +``` + +::: + +## Progress 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | --------------------------------------------- | --------- | ------------------ | +| percent | number | 0 | 百分比 | +| type | 'line' / 'circle' / 'dashboard' | 'line' | 类型 | +| status | 'success' / 'exception' / 'normal' / 'active' | 'normal' | 状态 | +| showInfo | boolean | true | 是否显示进度数值 | +| strokeColor | string | -- | 进度条颜色 | +| trailColor | string | -- | 未完成的分段颜色 | +| strokeWidth | number | -- | 进度条线的宽度 | +| width | number | 120 | 圆形进度条画布宽度 | +| size | 'default' / 'small' / number | 'default' | 进度条尺寸 | +| format | (percent: number) => string | -- | 内容的模板函数 | diff --git a/packages/docs/components/result/index.md b/packages/docs/components/result/index.md new file mode 100644 index 00000000..eb8ee5aa --- /dev/null +++ b/packages/docs/components/result/index.md @@ -0,0 +1,76 @@ +# Result 结果 + +用于反馈一系列操作任务的处理结果。 + +## 何时使用 + +- 当有重要操作需要向用户进行反馈,且反馈内容较多时。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 不同状态 + +:::demo + +```vue + +``` + +::: + +## HTTP 错误 + +:::demo + +```vue + +``` + +::: + +## Result 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| -------- | ---------------------------------------------------------------- | ------ | -------------- | +| status | 'success' / 'error' / 'info' / 'warning' / '404' / '403' / '500' | 'info' | 状态 | +| title | string | -- | 主标题 | +| subTitle | string | -- | 副标题 | +| icon | string | -- | 自定义图标类名 | + +## Result 插槽 + +| 插槽名 | 说明 | +| ------- | ------------------ | +| icon | 自定义图标 | +| extra | 自定义底部按钮区域 | +| default | 自定义内容区域 | diff --git a/packages/docs/components/segmented/index.md b/packages/docs/components/segmented/index.md new file mode 100644 index 00000000..50ba6ab2 --- /dev/null +++ b/packages/docs/components/segmented/index.md @@ -0,0 +1,78 @@ +# Segmented 分段控制器 + +用户在多个选项之间切换的分段控制器。 + +## 何时使用 + +- 当用户需要在多个数据/视图之间切换时。 +- 适合并列展示多个互斥选项的场景。 + +## 基本使用 + +:::demo + +```vue + + + +``` + +::: + +## 禁用 / 块级 + +:::demo + +```vue + +``` + +::: + +## 不同尺寸 + +:::demo + +```vue + +``` + +::: + +## Segmented 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ---------- | ------------------------------------- | -------- | ---------- | +| modelValue | string / number | -- | 当前选中值 | +| options | (string / number / SegmentedOption)[] | [] | 选项数据 | +| block | boolean | false | 撑满宽度 | +| disabled | boolean | false | 整体禁用 | +| size | 'small' / 'middle' / 'large' | 'middle' | 尺寸 | + +## Segmented 事件 + +| 事件名 | 参数 | 说明 | +| ------ | ------- | ---------- | +| change | (value) | 切换时触发 | diff --git a/packages/docs/components/skeleton/index.md b/packages/docs/components/skeleton/index.md new file mode 100644 index 00000000..ece40f4e --- /dev/null +++ b/packages/docs/components/skeleton/index.md @@ -0,0 +1,56 @@ +# Skeleton 骨架屏 + +在需要等待加载内容的位置提供一个占位图形组合。 + +## 何时使用 + +- 网络较慢,需要长时间等待加载处理的情况下。 +- 图文信息内容较多的列表/卡片中。 +- 只在第一次加载数据的时候使用。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 含头像 + 多行段落 + +:::demo + +```vue + +``` + +::: + +## 动画效果 + +:::demo + +```vue + +``` + +::: + +## Skeleton 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | -------------------------------- | ------ | ---------------------------------- | +| active | boolean | false | 是否展示动画效果 | +| loading | boolean | true | 为 true 时展示占位,否则展示子节点 | +| avatar | boolean / SkeletonAvatarShape | false | 是否显示头像占位图 | +| title | boolean / SkeletonTitleShape | true | 是否显示标题占位 | +| paragraph | boolean / SkeletonParagraphShape | true | 是否显示段落占位 | +| round | boolean | false | 段落骨架圆角化 | diff --git a/packages/docs/components/space/index.md b/packages/docs/components/space/index.md new file mode 100644 index 00000000..a872bcc6 --- /dev/null +++ b/packages/docs/components/space/index.md @@ -0,0 +1,97 @@ +# Space 间距 + +设置组件之间的间距。 + +## 何时使用 + +- 避免组件紧贴在一起,拉开统一的空间。 +- 适合行内元素的水平间距。 +- 可以设置各种水平对齐方式。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 垂直间距 + +:::demo + +```vue + +``` + +::: + +## 间距大小 + +:::demo + +```vue + +``` + +::: + +## 分隔符 + +:::demo + +```vue + +``` + +::: + +## Space 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | -------------------------------------------------------- | ------------ | ---------------------- | +| align | 'start' / 'end' / 'center' / 'baseline' | -- | 对齐方式 | +| direction | 'horizontal' / 'vertical' | 'horizontal' | 间距方向 | +| size | 'small' / 'middle' / 'large' / number / [number, number] | 'small' | 间距大小 | +| wrap | boolean | false | 自动换行(仅水平方向) | +| split | string | -- | 设置拆分 | diff --git a/packages/docs/components/spin/index.md b/packages/docs/components/spin/index.md new file mode 100644 index 00000000..0bd26fbc --- /dev/null +++ b/packages/docs/components/spin/index.md @@ -0,0 +1,77 @@ +# Spin 加载中 + +用于页面和区块的加载中状态。 + +## 何时使用 + +- 页面局部处于等待异步数据或正在渲染过程时,合适的加载动效会有效缓解用户的焦虑。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 各种大小与提示文字 + +:::demo + +```vue + +``` + +::: + +## 容器内嵌 + +:::demo + +```vue + + + +``` + +::: + +## Spin 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ---------- | ----------------------------- | --------- | ------------------------ | +| spinning | boolean | true | 是否旋转 | +| size | 'small' / 'default' / 'large' | 'default' | 大小 | +| tip | string | -- | 提示文字 | +| delay | number | 0 | 延迟显示加载效果(毫秒) | +| fullscreen | boolean | false | 全屏模式 | diff --git a/packages/docs/components/switch/index.md b/packages/docs/components/switch/index.md new file mode 100644 index 00000000..438bddfc --- /dev/null +++ b/packages/docs/components/switch/index.md @@ -0,0 +1,68 @@ +# Switch 开关 + +开关选择器。 + +## 何时使用 + +- 需要表示开关状态/两种状态之间的切换时。 +- 和 checkbox 的区别是,切换 switch 会直接触发状态改变。 + +## 基本使用 + +:::demo + +```vue + + + +``` + +::: + +## 文字与小尺寸 + +:::demo + +```vue + +``` + +::: + +## Switch 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------------- | ------------------------- | --------- | -------------- | +| modelValue | boolean / string / number | false | v-model 绑定值 | +| checkedValue | boolean / string / number | true | 选中时的值 | +| uncheckedValue | boolean / string / number | false | 未选中时的值 | +| disabled | boolean | false | 禁用 | +| loading | boolean | false | 加载状态 | +| size | 'default' / 'small' | 'default' | 大小 | +| checkedChildren | string | -- | 选中时的内容 | +| uncheckedChildren | string | -- | 未选中时的内容 | +| autofocus | boolean | false | 自动聚焦 | + +## Switch 事件 + +| 事件名 | 参数 | 说明 | +| ------ | ---------------------- | -------------- | +| change | (value, e: MouseEvent) | 状态改变时触发 | +| click | (e: MouseEvent) | 点击时触发 | diff --git a/packages/docs/components/tag/index.md b/packages/docs/components/tag/index.md new file mode 100644 index 00000000..2b364d6a --- /dev/null +++ b/packages/docs/components/tag/index.md @@ -0,0 +1,151 @@ +# Tag 标签 + +进行标记和分类的小标签。 + +## 何时使用 + +- 用于标记事物的属性和维度。 +- 进行分类。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 多彩标签 + +:::demo + +```vue + +``` + +::: + +## 可关闭 + +:::demo + +```vue + + + +``` + +::: + +## 自定义颜色 + +:::demo + +```vue + +``` + +::: + +## Tag 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| -------- | ------- | --------- | ------------------------------------ | +| color | string | 'default' | 颜色:预设色板名 / 状态色 / 任意 hex | +| bordered | boolean | true | 是否显示边框 | +| closable | boolean | false | 是否可关闭 | +| icon | string | -- | 图标类名 | + +## Tag 事件 + +| 事件名 | 参数 | 说明 | +| ------ | ---------- | -------------- | +| close | (e: Event) | 关闭按钮被点击 | + +## Tag 插槽 + +| 插槽名 | 说明 | +| ------- | ---------- | +| default | 标签内容 | +| icon | 自定义图标 | diff --git a/packages/docs/components/typography/index.md b/packages/docs/components/typography/index.md new file mode 100644 index 00000000..56e1d5f7 --- /dev/null +++ b/packages/docs/components/typography/index.md @@ -0,0 +1,153 @@ +# Typography 排版 + +文本的基本格式。 + +## 何时使用 + +- 在需要展示标题、段落、列表内容时使用,例如文章/博客/日志。 +- 需要一列基于文本的基础操作时,例如复制/省略/可编辑。 + +## 基本使用 + +:::demo + +```vue + +``` + +::: + +## 文本与超链接 + +:::demo + +```vue + +``` + +::: + +## 标题组件 + +:::demo + +```vue + +``` + +::: + +## 通用 Props(Text/Paragraph/Title/Link) + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | ---------------------------------------------- | ------ | ------------ | +| type | 'secondary' / 'success' / 'warning' / 'danger' | -- | 文本类型 | +| disabled | boolean | false | 是否禁用 | +| mark | boolean | false | 添加标记样式 | +| code | boolean | false | 添加代码样式 | +| keyboard | boolean | false | 添加键盘样式 | +| underline | boolean | false | 下划线 | +| delete | boolean | false | 删除线 | +| strong | boolean | false | 加粗 | +| italic | boolean | false | 斜体 | + +## Title 额外参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----- | ----------------- | ------ | -------- | +| level | 1 / 2 / 3 / 4 / 5 | 1 | 标题级别 | + +## Link 额外参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ------ | ------ | ------ | ------------- | +| href | string | -- | 链接地址 | +| target | string | -- | 链接的 target | diff --git a/packages/theme/themes/dark.ts b/packages/theme/themes/dark.ts index 08d9f4bc..a3eeedc9 100644 --- a/packages/theme/themes/dark.ts +++ b/packages/theme/themes/dark.ts @@ -1,93 +1,147 @@ +// 暗色主题基于 Ant Design 5.x dark algorithm 默认值 export default { - 'global-bg': '#202124', - 'global-bg-normal': '#202124', - 'base-bg': '#2E2F31', - 'base-bg-dark': '#2e2f31', - 'brand': '#5e7ce0', - 'brand-foil': '#313a61', - 'brand-hover': '#425288', - 'brand-active': '#526ecc', - 'brand-active-focus': '#344899', - 'contrast': '#C7000B', - 'text': '#E8E8E8', - 'text-weak': '#A0A0A0', - 'aide-text': '#909090', - 'aide-text-stress': '#A0A0A0', - 'placeholder': '#8A8A8A', + // ==================== 基础变量(兼容旧键,值已对齐 Ant Design Dark) ==================== + 'global-bg': '#000000', + 'global-bg-normal': '#141414', + 'base-bg': '#141414', + 'base-bg-dark': '#000000', + 'brand': '#1668dc', + 'brand-foil': '#111a2c', + 'brand-hover': '#3c89e8', + 'brand-active': '#1554ad', + 'brand-active-focus': '#103e85', + 'contrast': '#dc4446', + 'text': 'rgba(255, 255, 255, 0.85)', + 'text-weak': 'rgba(255, 255, 255, 0.65)', + 'aide-text': 'rgba(255, 255, 255, 0.45)', + 'aide-text-stress': 'rgba(255, 255, 255, 0.65)', + 'placeholder': 'rgba(255, 255, 255, 0.25)', 'light-text': '#ffffff', - 'dark-text': '#252b3a', - 'link': '#526ECC', - 'link-active': '#344899', - 'link-light': '#96adfa', - 'link-light-active': '#beccfa', - 'line': '#505153', - 'dividing-line': '#3D3E40', - 'block': '#606061', - 'area': '#34363A', - 'danger': '#f66f6a', - 'warning': '#fac20a', - 'waiting': '#5e6580', - 'success': '#50d4ab', - 'info': '#5e7ce0', - 'initial': '#64676e', - 'unavailable': '#5b5b5c', - 'shadow': 'rgba(17, 18, 19, 0.4)', - 'light-shadow': 'rgba(17, 18, 19, 0.5)', + 'dark-text': 'rgba(255, 255, 255, 0.85)', + 'link': '#1668dc', + 'link-active': '#1554ad', + 'link-light': '#3c89e8', + 'link-light-active': '#65a9f3', + 'line': '#424242', + 'dividing-line': '#303030', + 'block': '#1f1f1f', + 'area': '#1f1f1f', + 'danger': '#dc4446', + 'warning': '#d89614', + 'waiting': 'rgba(255, 255, 255, 0.45)', + 'success': '#49aa19', + 'info': '#1668dc', + 'initial': '#111a2c', + 'unavailable': '#1f1f1f', + 'shadow': 'rgba(0, 0, 0, 0.4)', + 'light-shadow': 'rgba(0, 0, 0, 0.2)', // 图标 - 'icon-text': '#E8E8E8', - 'icon-bg': '#2E2F31', - 'icon-fill': '#606061', - 'icon-fill-hover': '#73788a', - 'icon-fill-active': '#5e7ce0', - 'icon-fill-active-hover': '#526ecc', + 'icon-text': 'rgba(255, 255, 255, 0.85)', + 'icon-bg': '#141414', + 'icon-fill': 'rgba(255, 255, 255, 0.45)', + 'icon-fill-hover': 'rgba(255, 255, 255, 0.65)', + 'icon-fill-active': '#1668dc', + 'icon-fill-active-hover': '#3c89e8', // 表单 - 'form-control-line': '#505153', - 'form-control-line-hover': '#909090', - 'form-control-line-active': '#5e7ce0', - 'form-control-line-active-hover': '#344899', - 'list-item-active-bg': '#5e7ce0', - 'list-item-active-text': '#ffffff', - 'list-item-active-hover-bg': '#526ecc', - 'list-item-hover-bg': '#383838', - 'list-item-hover-text': '#526ecc', - 'list-item-selected-bg': '#454545', - 'list-item-strip-bg': '#383838', + 'form-control-line': '#424242', + 'form-control-line-hover': '#3c89e8', + 'form-control-line-active': '#1668dc', + 'form-control-line-active-hover': '#1554ad', + 'list-item-active-bg': '#1668dc', + 'list-item-active-text': 'rgba(255, 255, 255, 0.85)', + 'list-item-active-hover-bg': '#3c89e8', + 'list-item-hover-bg': 'rgba(255, 255, 255, 0.08)', + 'list-item-hover-text': '#3c89e8', + 'list-item-selected-bg': '#111a2c', + 'list-item-strip-bg': '#1f1f1f', // 禁用 - 'disabled-bg': '#3D3E44', - 'disabled-line': '#505153', - 'disabled-text': '#7D7D7D', - 'primary-disabled': '#2B3458', - 'icon-fill-active-disabled': '#2B3458', + 'disabled-bg': 'rgba(255, 255, 255, 0.08)', + 'disabled-line': '#424242', + 'disabled-text': 'rgba(255, 255, 255, 0.25)', + 'primary-disabled': '#15325b', + 'icon-fill-active-disabled': '#15325b', // 特殊背景色 - 'label-bg': '#46443F', - 'connected-overlay-bg': '#2F2F2F', - 'connected-overlay-line': '#526ecc', - 'fullscreen-overlay-bg': '#2E2F31', - 'feedback-overlay-bg': '#4C4C4C', - 'feedback-overlay-text': '#DFE1E6', - 'embed-search-bg': '#383838', - 'embed-search-bg-hover': '#3D3E40', - 'float-block-shadow': 'rgba(94, 124, 224, 0.3)', - 'highlight-overlay': 'rgba(255, 255, 255, 0.1)', - 'range-item-hover-bg': '#454545', + 'label-bg': '#1f1f1f', + 'connected-overlay-bg': '#1f1f1f', + 'connected-overlay-line': '#1668dc', + 'fullscreen-overlay-bg': '#141414', + 'feedback-overlay-bg': 'rgba(255, 255, 255, 0.85)', + 'feedback-overlay-text': '#000000', + 'embed-search-bg': '#1f1f1f', + 'embed-search-bg-hover': '#262626', + 'float-block-shadow': 'rgba(22, 104, 220, 0.32)', + 'highlight-overlay': 'rgba(0, 0, 0, 0.45)', + 'range-item-hover-bg': '#111a2c', // 按钮 - 'primary': '#5e7ce0', - 'primary-hover': '#425288', - 'primary-active': '#344899', - 'contrast-hover': '#D64A52', - 'contrast-active': '#B12220', - 'secondary': '#6c757d', + 'primary': '#1668dc', + 'primary-hover': '#3c89e8', + 'primary-active': '#1554ad', + 'contrast-hover': '#e86e6b', + 'contrast-active': '#ad2d2c', + 'secondary': 'rgba(255, 255, 255, 0.65)', // 状态 - 'danger-line': '#985C5A', - 'danger-bg': '#4B3A39', - 'warning-line': '#8D6138', - 'warning-bg': '#554434', - 'info-line': '#546BB7', - 'info-bg': '#383D4F', - 'success-line': '#5D887D', - 'success-bg': '#304642', - 'primary-line': '#546BB7', - 'primary-bg': '#383D4F', - 'default-line': '#5e7ce0', - 'default-bg': '#383838', + 'danger-line': '#58181c', + 'danger-bg': '#2a1215', + 'warning-line': '#594214', + 'warning-bg': '#2b2111', + 'info-line': '#15325b', + 'info-bg': '#111a2c', + 'success-line': '#274916', + 'success-bg': '#162312', + 'primary-line': '#15325b', + 'primary-bg': '#111a2c', + 'default-line': '#424242', + 'default-bg': '#1f1f1f', + + // ==================== Ant Design 5.x Dark Token ==================== + 'color-primary': '#1668dc', + 'color-primary-hover': '#3c89e8', + 'color-primary-active': '#1554ad', + 'color-primary-bg': '#111a2c', + 'color-primary-bg-hover': '#112545', + 'color-primary-border': '#15325b', + 'color-primary-border-hover': '#15417e', + 'color-success': '#49aa19', + 'color-success-hover': '#6abe39', + 'color-success-active': '#3c8618', + 'color-success-bg': '#162312', + 'color-success-border': '#274916', + 'color-warning': '#d89614', + 'color-warning-hover': '#e8b339', + 'color-warning-active': '#aa7714', + 'color-warning-bg': '#2b2111', + 'color-warning-border': '#594214', + 'color-error': '#dc4446', + 'color-error-hover': '#e86e6b', + 'color-error-active': '#ad2d2c', + 'color-error-bg': '#2a1215', + 'color-error-border': '#58181c', + 'color-info': '#1668dc', + 'color-info-hover': '#3c89e8', + 'color-info-bg': '#111a2c', + 'color-info-border': '#15325b', + 'color-text': 'rgba(255, 255, 255, 0.85)', + 'color-text-secondary': 'rgba(255, 255, 255, 0.65)', + 'color-text-tertiary': 'rgba(255, 255, 255, 0.45)', + 'color-text-quaternary': 'rgba(255, 255, 255, 0.25)', + 'color-text-disabled': 'rgba(255, 255, 255, 0.25)', + 'color-text-placeholder': 'rgba(255, 255, 255, 0.25)', + 'color-text-heading': 'rgba(255, 255, 255, 0.85)', + 'color-text-light-solid': '#ffffff', + 'color-bg-base': '#000000', + 'color-bg-container': '#141414', + 'color-bg-elevated': '#1f1f1f', + 'color-bg-layout': '#000000', + 'color-bg-spotlight': '#424242', + 'color-bg-mask': 'rgba(0, 0, 0, 0.45)', + 'color-bg-text-hover': 'rgba(255, 255, 255, 0.06)', + 'color-bg-text-active': 'rgba(255, 255, 255, 0.15)', + 'color-border': '#424242', + 'color-border-secondary': '#303030', + 'color-border-disabled': 'rgba(255, 255, 255, 0.08)', + 'color-split': 'rgba(253, 253, 253, 0.12)', + 'color-fill': 'rgba(255, 255, 255, 0.18)', + 'color-fill-secondary': 'rgba(255, 255, 255, 0.12)', + 'color-fill-tertiary': 'rgba(255, 255, 255, 0.08)', + 'color-fill-quaternary': 'rgba(255, 255, 255, 0.04)', } diff --git a/packages/theme/themes/light.ts b/packages/theme/themes/light.ts index 0f64cae1..eca0b888 100644 --- a/packages/theme/themes/light.ts +++ b/packages/theme/themes/light.ts @@ -1,129 +1,131 @@ +// 主题变量基于 Ant Design 5.x 默认 SeedToken / MapToken +// 参考: https://ant.design/docs/react/customize-theme-cn 与 https://ant.design/docs/spec/colors-cn export default { - // 基础变量 - 'global-bg': '#f3f6f8', + // ==================== 基础变量(兼容旧键,值已对齐 Ant Design) ==================== + 'global-bg': '#f5f5f5', 'global-bg-normal': '#ffffff', 'base-bg': '#ffffff', - 'base-bg-dark': '#333854', - 'brand': '#5e7ce0', - 'brand-foil': '#859bff', - 'brand-hover': '#7693f5', - 'brand-active': '#526ecc', - 'brand-active-focus': '#344899', - 'contrast': '#c7000b', - 'text': '#252b3a', - 'text-weak': '#575d6c', - 'aide-text': '#8a8e99', - 'aide-text-stress': '#575d6c', - 'placeholder': '#8a8e99', + 'base-bg-dark': 'rgba(0, 0, 0, 0.85)', + 'brand': '#1677ff', + 'brand-foil': '#e6f4ff', + 'brand-hover': '#4096ff', + 'brand-active': '#0958d9', + 'brand-active-focus': '#003eb3', + 'contrast': '#ff4d4f', + 'text': 'rgba(0, 0, 0, 0.88)', + 'text-weak': 'rgba(0, 0, 0, 0.65)', + 'aide-text': 'rgba(0, 0, 0, 0.45)', + 'aide-text-stress': 'rgba(0, 0, 0, 0.65)', + 'placeholder': 'rgba(0, 0, 0, 0.25)', 'light-text': '#ffffff', - 'dark-text': '#252b3a', - 'link': '#526ecc', - 'link-active': '#526ecc', - 'link-light': '#96adfa', - 'link-light-active': '#beccfa', - 'line': '#adb0b8', - 'dividing-line': '#dfe1e6', + 'dark-text': 'rgba(0, 0, 0, 0.88)', + 'link': '#1677ff', + 'link-active': '#0958d9', + 'link-light': '#4096ff', + 'link-light-active': '#69b1ff', + 'line': '#d9d9d9', + 'dividing-line': '#f0f0f0', 'block': '#ffffff', - 'area': '#f8f8f8', - 'danger': '#f66f6a', - 'warning': '#fac20a', - 'waiting': '#9faad7', - 'success': '#50d4ab', - 'info': '#5e7ce0', - 'initial': '#e9edfa', - 'unavailable': '#f5f5f6', - 'shadow': 'rgba(0, 0, 0, 0.2)', - 'light-shadow': 'rgba(0, 0, 0, 0.1)', + 'area': '#fafafa', + 'danger': '#ff4d4f', + 'warning': '#faad14', + 'waiting': 'rgba(0, 0, 0, 0.45)', + 'success': '#52c41a', + 'info': '#1677ff', + 'initial': '#e6f4ff', + 'unavailable': '#f5f5f5', + 'shadow': 'rgba(0, 0, 0, 0.15)', + 'light-shadow': 'rgba(0, 0, 0, 0.06)', // 图标 - 'icon-text': '#252b3a', + 'icon-text': 'rgba(0, 0, 0, 0.88)', 'icon-bg': '#ffffff', - 'icon-fill': '#d3d5d9', - 'icon-fill-hover': '#adb5ce', - 'icon-fill-active': '#5e7ce0', - 'icon-fill-active-hover': '#526ecc', + 'icon-fill': 'rgba(0, 0, 0, 0.45)', + 'icon-fill-hover': 'rgba(0, 0, 0, 0.65)', + 'icon-fill-active': '#1677ff', + 'icon-fill-active-hover': '#4096ff', // 表单 - 'form-control-line': '#adb0b8', - 'form-control-line-hover': '#575d6c', - 'form-control-line-active': '#5e7ce0', - 'form-control-line-active-hover': '#344899', - 'list-item-active-bg': '#5e7ce0', + 'form-control-line': '#d9d9d9', + 'form-control-line-hover': '#4096ff', + 'form-control-line-active': '#1677ff', + 'form-control-line-active-hover': '#0958d9', + 'list-item-active-bg': '#1677ff', 'list-item-active-text': '#ffffff', - 'list-item-active-hover-bg': '#526ecc', - 'list-item-hover-bg': '#f2f5fc', - 'list-item-hover-text': '#526ecc', - 'list-item-selected-bg': '#e9edfa', - 'list-item-strip-bg': '#f2f5fc', + 'list-item-active-hover-bg': '#4096ff', + 'list-item-hover-bg': 'rgba(0, 0, 0, 0.04)', + 'list-item-hover-text': '#1677ff', + 'list-item-selected-bg': '#e6f4ff', + 'list-item-strip-bg': '#fafafa', // 禁用 - 'disabled-bg': '#f5f5f6', - 'disabled-line': '#dfe1e6', - 'disabled-text': '#adb0b8', - 'primary-disabled': '#beccfa', - 'icon-fill-active-disabled': '#beccfa', + 'disabled-bg': 'rgba(0, 0, 0, 0.04)', + 'disabled-line': '#d9d9d9', + 'disabled-text': 'rgba(0, 0, 0, 0.25)', + 'primary-disabled': '#91caff', + 'icon-fill-active-disabled': '#91caff', // 特殊背景色 - 'label-bg': '#eef0f5', + 'label-bg': '#fafafa', 'connected-overlay-bg': '#ffffff', - 'connected-overlay-line': '#526ecc', + 'connected-overlay-line': '#1677ff', 'fullscreen-overlay-bg': '#ffffff', - 'feedback-overlay-bg': '#464d6e', - 'feedback-overlay-text': '#dfe1e6', - 'embed-search-bg': '#f2f5fc', - 'embed-search-bg-hover': '#eef0f5', - 'float-block-shadow': 'rgba(94, 124, 224, 0.3)', + 'feedback-overlay-bg': 'rgba(0, 0, 0, 0.85)', + 'feedback-overlay-text': '#ffffff', + 'embed-search-bg': '#fafafa', + 'embed-search-bg-hover': '#f0f0f0', + 'float-block-shadow': 'rgba(22, 119, 255, 0.16)', 'highlight-overlay': 'rgba(255, 255, 255, 0.8)', - 'range-item-hover-bg': '#e9edfa', + 'range-item-hover-bg': '#e6f4ff', // 按钮 - 'primary': '#5e7ce0', - 'primary-hover': '#7693f5', - 'primary-active': '#344899', - 'contrast-hover': '#d64a52', - 'contrast-active': '#b12220', - 'secondary': '#6c757d', + 'primary': '#1677ff', + 'primary-hover': '#4096ff', + 'primary-active': '#0958d9', + 'contrast-hover': '#ff7875', + 'contrast-active': '#d9363e', + 'secondary': 'rgba(0, 0, 0, 0.65)', // 状态 - 'danger-line': '#f66f6a', - 'danger-bg': '#ffeeed', - 'warning-line': '#fa9841', - 'warning-bg': '#fff3e8', - 'info-line': '#5e7ce0', - 'info-bg': '#f2f5fc', - 'success-line': '#50d4ab', - 'success-bg': '#edfff9', - 'primary-line': '#5e7ce0', - 'primary-bg': '#f2f5fc', - 'default-line': '#5e7ce0', - 'default-bg': '#f3f6f8', + 'danger-line': '#ffccc7', + 'danger-bg': '#fff2f0', + 'warning-line': '#ffe58f', + 'warning-bg': '#fffbe6', + 'info-line': '#91caff', + 'info-bg': '#e6f4ff', + 'success-line': '#b7eb8f', + 'success-bg': '#f6ffed', + 'primary-line': '#91caff', + 'primary-bg': '#e6f4ff', + 'default-line': '#d9d9d9', + 'default-bg': '#ffffff', // 字体设置相关 - 'font-size': '12px', - 'font-size-card-title': '14px', - 'font-size-page-title': '16px', - 'font-size-modal-title': '18px', - 'font-size-price': '20px', - 'font-size-data-overview': '24px', + 'font-size': '14px', + 'font-size-card-title': '16px', + 'font-size-page-title': '20px', + 'font-size-modal-title': '16px', + 'font-size-price': '24px', + 'font-size-data-overview': '30px', 'font-size-icon': '16px', 'font-size-sm': '12px', - 'font-size-md': '12px', - 'font-size-lg': '14px', - 'font-title-weight': 'bold', - 'font-content-weight': 'normal', - 'line-height-base': '1.5', + 'font-size-md': '14px', + 'font-size-lg': '16px', + 'font-title-weight': '600', + 'font-content-weight': '400', + 'line-height-base': '1.5714285714285714', // 圆角 - 'border-radius': '4px', - 'border-radius-feedback': '4px', - 'border-radius-card': '6px', + 'border-radius': '6px', + 'border-radius-feedback': '8px', + 'border-radius-card': '8px', // 阴影 - 'shadow-length-base': '0 1px 4px 0', - 'shadow-length-slide-left': '-2px 0 8px 0', - 'shadow-length-slide-right': '2px 0 8px 0', - 'shadow-length-connected-overlay': '0 2px 8px 0', - 'shadow-length-hover': '0 4px 16px 0', - 'shadow-length-feedback-overlay': '0 4px 16px 0', - 'shadow-fullscreen-overlay': '0 8px 40px 0', + 'shadow-length-base': '0 1px 2px 0', + 'shadow-length-slide-left': '-6px 0 16px 0', + 'shadow-length-slide-right': '6px 0 16px 0', + 'shadow-length-connected-overlay': '0 6px 16px 0', + 'shadow-length-hover': '0 6px 16px 0', + 'shadow-length-feedback-overlay': '0 6px 16px 0', + 'shadow-fullscreen-overlay': '0 6px 16px 0', // 动效 - 'animation-duration-slow': '300ms', - 'animation-duration-base': '200ms', - 'animation-duration-fast': '100ms', - 'animation-ease-in': 'cubic-bezier(0.5, 0, 0.84, 0.25)', - 'animation-ease-out': 'cubic-bezier(0.16, 0.75, 0.5, 1)', - 'animation-ease-in-out': 'cubic-bezier(0.5, 0.05, 0.5, 0.95)', + 'animation-duration-slow': '0.3s', + 'animation-duration-base': '0.2s', + 'animation-duration-fast': '0.1s', + 'animation-ease-in': 'cubic-bezier(0.7, 0, 0.84, 0)', + 'animation-ease-out': 'cubic-bezier(0.215, 0.61, 0.355, 1)', + 'animation-ease-in-out': 'cubic-bezier(0.78, 0.14, 0.15, 0.86)', 'animation-ease-in-smooth': 'cubic-bezier(0.645, 0.045, 0.355, 1)', 'animation-linear': 'cubic-bezier(0, 0, 1, 1)', // zIndex @@ -133,8 +135,144 @@ export default { 'z-index-modal': '1050', 'z-index-drawer': '1040', 'z-index-framework': '1000', - // 组件的size + // 组件 size 'size-sm': '24px', 'size-md': '32px', 'size-lg': '40px', + + // ==================== Ant Design 5.x Token(新组件直接使用) ==================== + // Color · Brand + 'color-primary': '#1677ff', + 'color-primary-hover': '#4096ff', + 'color-primary-active': '#0958d9', + 'color-primary-bg': '#e6f4ff', + 'color-primary-bg-hover': '#bae0ff', + 'color-primary-border': '#91caff', + 'color-primary-border-hover': '#69b1ff', + // Color · Status + 'color-success': '#52c41a', + 'color-success-hover': '#73d13d', + 'color-success-active': '#389e0d', + 'color-success-bg': '#f6ffed', + 'color-success-border': '#b7eb8f', + 'color-warning': '#faad14', + 'color-warning-hover': '#ffc53d', + 'color-warning-active': '#d48806', + 'color-warning-bg': '#fffbe6', + 'color-warning-border': '#ffe58f', + 'color-error': '#ff4d4f', + 'color-error-hover': '#ff7875', + 'color-error-active': '#d9363e', + 'color-error-bg': '#fff2f0', + 'color-error-border': '#ffccc7', + 'color-info': '#1677ff', + 'color-info-hover': '#4096ff', + 'color-info-bg': '#e6f4ff', + 'color-info-border': '#91caff', + // Color · Text + 'color-text': 'rgba(0, 0, 0, 0.88)', + 'color-text-secondary': 'rgba(0, 0, 0, 0.65)', + 'color-text-tertiary': 'rgba(0, 0, 0, 0.45)', + 'color-text-quaternary': 'rgba(0, 0, 0, 0.25)', + 'color-text-disabled': 'rgba(0, 0, 0, 0.25)', + 'color-text-placeholder': 'rgba(0, 0, 0, 0.25)', + 'color-text-heading': 'rgba(0, 0, 0, 0.88)', + 'color-text-light-solid': '#ffffff', + // Color · Background + 'color-bg-base': '#ffffff', + 'color-bg-container': '#ffffff', + 'color-bg-elevated': '#ffffff', + 'color-bg-layout': '#f5f5f5', + 'color-bg-spotlight': 'rgba(0, 0, 0, 0.85)', + 'color-bg-mask': 'rgba(0, 0, 0, 0.45)', + 'color-bg-text-hover': 'rgba(0, 0, 0, 0.06)', + 'color-bg-text-active': 'rgba(0, 0, 0, 0.15)', + // Color · Border + 'color-border': '#d9d9d9', + 'color-border-secondary': '#f0f0f0', + 'color-border-disabled': 'rgba(0, 0, 0, 0.04)', + 'color-split': 'rgba(5, 5, 5, 0.06)', + // Color · Fill + 'color-fill': 'rgba(0, 0, 0, 0.15)', + 'color-fill-secondary': 'rgba(0, 0, 0, 0.06)', + 'color-fill-tertiary': 'rgba(0, 0, 0, 0.04)', + 'color-fill-quaternary': 'rgba(0, 0, 0, 0.02)', + // 色板(10 阶蓝色,用于 Tag/Badge 预设色) + 'blue-1': '#e6f4ff', + 'blue-2': '#bae0ff', + 'blue-3': '#91caff', + 'blue-4': '#69b1ff', + 'blue-5': '#4096ff', + 'blue-6': '#1677ff', + 'blue-7': '#0958d9', + 'cyan-6': '#13c2c2', + 'green-6': '#52c41a', + 'magenta-6': '#eb2f96', + 'pink-6': '#eb2f96', + 'red-6': '#f5222d', + 'orange-6': '#fa8c16', + 'yellow-6': '#fadb14', + 'volcano-6': '#fa541c', + 'geekblue-6': '#2f54eb', + 'lime-6': '#a0d911', + 'gold-6': '#faad14', + 'purple-6': '#722ed1', + // 圆角阶梯 + 'border-radius-lg': '8px', + 'border-radius-sm': '4px', + 'border-radius-xs': '2px', + 'border-radius-outer': '4px', + // 字号阶梯 + 'font-size-xl': '20px', + 'font-size-heading-1': '38px', + 'font-size-heading-2': '30px', + 'font-size-heading-3': '24px', + 'font-size-heading-4': '20px', + 'font-size-heading-5': '16px', + // 行高 + 'line-height-lg': '1.5', + 'line-height-sm': '1.66', + 'line-height-heading-1': '1.21052', + 'line-height-heading-2': '1.26666', + 'line-height-heading-3': '1.33333', + 'line-height-heading-4': '1.4', + 'line-height-heading-5': '1.5', + // 字体族 + 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + 'font-family-code': 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace', + // 控件高度 + 'control-height': '32px', + 'control-height-sm': '24px', + 'control-height-lg': '40px', + 'control-height-xs': '16px', + // 阴影 + 'box-shadow': '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', + 'box-shadow-secondary': '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', + 'box-shadow-tertiary': '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)', + // 动效 + 'motion-duration-slow': '0.3s', + 'motion-duration-mid': '0.2s', + 'motion-duration-fast': '0.1s', + 'motion-ease-in-out': 'cubic-bezier(0.645, 0.045, 0.355, 1)', + 'motion-ease-out': 'cubic-bezier(0.215, 0.61, 0.355, 1)', + 'motion-ease-in': 'cubic-bezier(0.55, 0.055, 0.675, 0.19)', + 'motion-ease-out-back': 'cubic-bezier(0.12, 0.4, 0.29, 1.46)', + 'motion-ease-in-back': 'cubic-bezier(0.71, -0.46, 0.88, 0.6)', + 'motion-ease-out-circ': 'cubic-bezier(0.08, 0.82, 0.17, 1)', + 'motion-ease-in-out-circ': 'cubic-bezier(0.78, 0.14, 0.15, 0.86)', + // 间距阶梯(基于 sizeUnit=4 sizeStep=4) + 'padding-xxs': '4px', + 'padding-xs': '8px', + 'padding-sm': '12px', + 'padding': '16px', + 'padding-md': '20px', + 'padding-lg': '24px', + 'padding-xl': '32px', + 'margin-xxs': '4px', + 'margin-xs': '8px', + 'margin-sm': '12px', + 'margin': '16px', + 'margin-md': '20px', + 'margin-lg': '24px', + 'margin-xl': '32px', } From bf785f60684c102d08fb56b019427248b1936836 Mon Sep 17 00:00:00 2001 From: zcj Date: Mon, 4 May 2026 13:22:18 +0800 Subject: [PATCH 002/277] =?UTF-8?q?feat:=202.x=20=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ccui/ui/breadcrumb/src/breadcrumb.scss | 8 +++++--- packages/ccui/ui/result/src/result.tsx | 14 +++++++------- packages/ccui/ui/skeleton/src/skeleton.scss | 6 +++--- packages/ccui/ui/skeleton/src/skeleton.tsx | 6 +++--- packages/ccui/ui/skeleton/test/skeleton.test.ts | 2 +- packages/ccui/ui/space/src/space.scss | 2 +- packages/ccui/ui/spin/src/spin.tsx | 8 ++++---- packages/cli/commands/generate-theme.js | 13 ++++++++++++- 8 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/ccui/ui/breadcrumb/src/breadcrumb.scss b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss index b2f2d992..e8c408aa 100644 --- a/packages/ccui/ui/breadcrumb/src/breadcrumb.scss +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss @@ -15,20 +15,22 @@ &__link { color: $ccui-color-text-secondary; - text-decoration: none; + text-decoration: none !important; transition: color $ccui-motion-duration-mid; &:hover { color: $ccui-color-text; + text-decoration: none !important; } } a.#{$cls-prefix}-breadcrumb__link { padding: 0 4px; - color: $ccui-color-text; + color: $ccui-color-text-secondary; cursor: pointer; &:hover { + color: $ccui-color-text; background-color: rgba(0, 0, 0, 0.06); border-radius: $ccui-border-radius-sm; } @@ -40,7 +42,7 @@ &__separator { margin: 0 8px; - color: $ccui-color-split; + color: $ccui-color-text-quaternary; user-select: none; } diff --git a/packages/ccui/ui/result/src/result.tsx b/packages/ccui/ui/result/src/result.tsx index d1a23f06..fc044f61 100644 --- a/packages/ccui/ui/result/src/result.tsx +++ b/packages/ccui/ui/result/src/result.tsx @@ -12,13 +12,13 @@ const STATUS_ICON: Record = { } const ICON_COLOR: Record = { - success: 'var(--ccui-color-success)', - error: 'var(--ccui-color-error)', - info: 'var(--ccui-color-info)', - warning: 'var(--ccui-color-warning)', - 404: 'var(--ccui-color-info)', - 403: 'var(--ccui-color-info)', - 500: 'var(--ccui-color-info)', + success: '#52c41a', + error: '#ff4d4f', + info: '#1677ff', + warning: '#faad14', + 404: '#1677ff', + 403: '#1677ff', + 500: '#1677ff', } const STATUS_TEXT: Record = { diff --git a/packages/ccui/ui/skeleton/src/skeleton.scss b/packages/ccui/ui/skeleton/src/skeleton.scss index f2e7cecd..4e223f54 100644 --- a/packages/ccui/ui/skeleton/src/skeleton.scss +++ b/packages/ccui/ui/skeleton/src/skeleton.scss @@ -37,7 +37,7 @@ $skeleton-bg-active: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, padding: 0; list-style: none; - li { + &-row { width: 100%; height: 16px; margin-top: 16px; @@ -52,7 +52,7 @@ $skeleton-bg-active: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, &--round { .#{$cls-prefix}-skeleton__title, - .#{$cls-prefix}-skeleton__paragraph li { + .#{$cls-prefix}-skeleton__paragraph-row { border-radius: 100px; } } @@ -60,7 +60,7 @@ $skeleton-bg-active: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, &--active { .#{$cls-prefix}-skeleton__avatar, .#{$cls-prefix}-skeleton__title, - .#{$cls-prefix}-skeleton__paragraph li { + .#{$cls-prefix}-skeleton__paragraph-row { background: $skeleton-bg-active; background-size: 400% 100%; animation: ccuiSkeletonLoading 1.4s ease infinite; diff --git a/packages/ccui/ui/skeleton/src/skeleton.tsx b/packages/ccui/ui/skeleton/src/skeleton.tsx index 2ec29e54..66b25c6a 100644 --- a/packages/ccui/ui/skeleton/src/skeleton.tsx +++ b/packages/ccui/ui/skeleton/src/skeleton.tsx @@ -93,11 +93,11 @@ export default defineComponent({
{props.title &&

} {total > 0 && ( -
    +
    {Array.from({ length: total }).map((_, i) => ( -
  • +
    ))} -
+

)}
diff --git a/packages/ccui/ui/skeleton/test/skeleton.test.ts b/packages/ccui/ui/skeleton/test/skeleton.test.ts index e6001458..74351a7a 100644 --- a/packages/ccui/ui/skeleton/test/skeleton.test.ts +++ b/packages/ccui/ui/skeleton/test/skeleton.test.ts @@ -30,6 +30,6 @@ describe('skeleton', () => { it('respects paragraph rows config', () => { const wrapper = mount(Skeleton, { props: { paragraph: { rows: 5 } } }) - expect(wrapper.findAll(`${ns.e('paragraph')} li`).length).toBe(5) + expect(wrapper.findAll(ns.e('paragraph-row')).length).toBe(5) }) }) diff --git a/packages/ccui/ui/space/src/space.scss b/packages/ccui/ui/space/src/space.scss index a71f16c4..ccca9d3b 100644 --- a/packages/ccui/ui/space/src/space.scss +++ b/packages/ccui/ui/space/src/space.scss @@ -38,6 +38,6 @@ &__split { display: inline-flex; align-items: center; - color: $ccui-color-split; + color: $ccui-color-text-quaternary; } } diff --git a/packages/ccui/ui/spin/src/spin.tsx b/packages/ccui/ui/spin/src/spin.tsx index 370eef76..91b67f96 100644 --- a/packages/ccui/ui/spin/src/spin.tsx +++ b/packages/ccui/ui/spin/src/spin.tsx @@ -49,10 +49,10 @@ export default defineComponent({ } return ( - - - - + + + + ) } diff --git a/packages/cli/commands/generate-theme.js b/packages/cli/commands/generate-theme.js index 20c3a335..b86a667b 100644 --- a/packages/cli/commands/generate-theme.js +++ b/packages/cli/commands/generate-theme.js @@ -6,13 +6,24 @@ const { CSS_CLASS_PREFIX } = require('../shared/constant') const lightTheme = require('theme/themes/light.ts').default const darkTheme = require('theme/themes/dark.ts').default -const lightFileStr = Object.entries(lightTheme) +const lightScssVars = Object.entries(lightTheme) .map( ([key, value]) => `$${CSS_CLASS_PREFIX}-${key}: var(--${CSS_CLASS_PREFIX}-${key}, ${value})`, ) .join(';\n') +const lightCssVars = Object.entries(lightTheme) + .map(([key, value]) => ` --${CSS_CLASS_PREFIX}-${key}: ${value};`) + .join('\n') + +const lightFileStr = `${lightScssVars}; + +:root { +${lightCssVars} +} +` + let darkCssVariablesStr = Object.entries(darkTheme) .map(([key, value]) => `--${CSS_CLASS_PREFIX}-${key}: ${value}`) .join(';\n') From 33708a6c782e014f3029533ff0d5cbc3db788c03 Mon Sep 17 00:00:00 2001 From: zcj Date: Mon, 4 May 2026 19:34:44 +0800 Subject: [PATCH 003/277] =?UTF-8?q?chore:=20=E5=85=A8=E9=87=8F=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=20Vite+=20=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 把 vite/vitest/相关插件升到 Vite 8 + Vitest 4.1 兼容版本 - 用 vp migrate 接入 Vite+:oxlint + oxfmt 替代 ESLint/Prettier, vp test 替代独立 vitest,vp check 一站式 format/lint/typecheck - 测试 import 从 vitest 改到 vite-plus/test,root + ccui + docs 的 vite.config.ts 用 vite-plus.defineConfig - pnpm-workspace.yaml 用 catalog 统一 vite/vitest/vite-plus - 卸载 ESLint 体系(@antfu/eslint-config 等)、husky、lint-staged - VitePress 升 2.0-alpha + oxc-minify,移除 esbuild 依赖 - VitePress 2 把 themeConfig.algolia 改为 search.provider 写法 - oxfmt 配置为 antfu 风格(单引号、无分号、trailing comma all) - 修 vp check 报告的全部 type/lint 错误:CSS shim、LabelType 泛化、Button3DType/IStatusType union 扩展、9 处 nextTick 加 void - 移除 cli 的 esbuild-register,改 Node 24 原生 type stripping 加载 theme/themes/{light,dark}.ts;build.js 用 oxc 替代 esbuild 选项 - 根 + cli package.json 加 engines.node: ">=22.10.0" - pre-commit hook 改用 git rev-parse 切到仓库根再跑 vp staged Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy.yml | 1 - .vite-hooks/pre-commit | 1 + AGENTS.md | 15 + eslint.config.mjs | 18 - package.json | 31 +- packages/ccui/env.d.ts | 11 + packages/ccui/index.d.ts | 6 +- packages/ccui/package.json | 35 +- packages/ccui/tsconfig.json | 8 +- packages/ccui/tsconfig.node.json | 4 +- packages/ccui/ui/avatar/src/avatar-types.ts | 2 +- packages/ccui/ui/avatar/src/avatar.tsx | 27 +- .../ui/avatar/src/components/icon-body.tsx | 13 +- .../avatar/src/components/icon-img-error.tsx | 14 +- .../ui/avatar/src/components/icon-nobody.tsx | 8 +- .../composables/use-get-background-color.ts | 14 +- .../src/composables/use-get-display-name.ts | 14 +- packages/ccui/ui/avatar/test/avatar.test.ts | 4 +- .../ccui/ui/button-3d/src/button-3d-types.ts | 2 +- packages/ccui/ui/button-3d/src/button-3d.tsx | 18 +- .../ccui/ui/button-3d/test/button-3d.test.ts | 2 +- packages/ccui/ui/button/src/button.tsx | 4 +- packages/ccui/ui/button/test/button.test.ts | 13 +- packages/ccui/ui/calendar/src/calendar.tsx | 27 +- .../ccui/ui/calendar/test/calendar.test.ts | 26 +- packages/ccui/ui/card/test/card.test.ts | 2 +- .../ccui/ui/check-box/src/check-box-group.tsx | 21 +- .../ccui/ui/check-box/src/check-box-types.ts | 12 +- packages/ccui/ui/check-box/src/check-box.tsx | 24 +- .../ccui/ui/check-box/test/check-box.test.ts | 2 +- packages/ccui/ui/divider/src/divider.tsx | 6 +- packages/ccui/ui/divider/test/divider.test.ts | 2 +- .../ui/input-number/src/input-number-types.ts | 8 +- .../ccui/ui/input-number/src/input-number.tsx | 46 +- .../ui/input-number/test/input-number.test.ts | 2 +- packages/ccui/ui/input/index.ts | 4 +- packages/ccui/ui/input/src/input.tsx | 54 +- packages/ccui/ui/input/test/input.test.ts | 2 +- packages/ccui/ui/popover/index.ts | 4 +- packages/ccui/ui/popover/src/popover-types.ts | 26 +- packages/ccui/ui/popover/src/popover.tsx | 135 +- packages/ccui/ui/popover/test/popover.test.ts | 21 +- packages/ccui/ui/radio/src/radio-group.tsx | 15 +- packages/ccui/ui/radio/src/radio-types.ts | 3 +- packages/ccui/ui/radio/src/radio.tsx | 14 +- packages/ccui/ui/radio/test/radio.test.ts | 2 +- packages/ccui/ui/rate/src/rate.tsx | 33 +- packages/ccui/ui/rate/test/rate.test.ts | 11 +- .../ccui/ui/shared/hooks/use-namespace.ts | 12 +- .../src/composables/use-slider-tooltip.ts | 16 +- .../ui/slider/src/composables/use-slider.ts | 80 +- packages/ccui/ui/slider/src/slider-types.ts | 2 +- packages/ccui/ui/slider/src/slider.tsx | 110 +- packages/ccui/ui/slider/test/slider.test.ts | 2 +- packages/ccui/ui/status/src/status-types.ts | 9 +- packages/ccui/ui/status/test/status.test.ts | 2 +- .../ccui/ui/tabs/src/components/tab/tab.tsx | 10 +- .../ui/tabs/src/components/tabs-nav/index.tsx | 13 +- packages/ccui/ui/tabs/src/tabs-types.ts | 7 +- packages/ccui/ui/tabs/src/tabs.tsx | 17 +- packages/ccui/ui/tabs/test/tabs.test.ts | 2 +- .../ccui/ui/timeline/src/timeline-item.tsx | 27 +- packages/ccui/ui/timeline/src/timeline.tsx | 6 +- .../ccui/ui/timeline/test/timeline.test.ts | 11 +- packages/ccui/ui/tooltip/src/tooltip-types.ts | 34 +- packages/ccui/ui/tooltip/src/tooltip.tsx | 60 +- packages/ccui/ui/tooltip/test/tooltip.test.ts | 22 +- .../ccui/ui/tree/src/components/icon-open.tsx | 10 +- .../ui/tree/src/composables/use-toggle.ts | 6 +- packages/ccui/ui/tree/src/tree.tsx | 32 +- packages/ccui/vite.config.ts | 3 +- .../cli/commands/build-nuxt-auto-import.js | 10 +- packages/cli/commands/build.js | 28 +- packages/cli/commands/code-check.js | 18 +- packages/cli/commands/create.js | 85 +- packages/cli/commands/generate-theme.js | 49 +- packages/cli/commands/release.js | 31 +- packages/cli/index.js | 29 +- packages/cli/inquirers/component.js | 5 +- packages/cli/package.json | 24 +- packages/cli/shared/constant.js | 33 +- packages/cli/shared/utils.js | 123 +- packages/cli/templates/component.js | 64 +- packages/cli/templates/vitepress-sidebar.js | 28 +- packages/cli/templates/vue-ui.js | 4 +- packages/docs/.vitepress/config.ts | 20 +- packages/docs/components/avatar/index.md | 9 +- packages/docs/components/button-3d/index.md | 54 +- packages/docs/components/button/index.md | 277 +- packages/docs/components/calendar/index.md | 26 +- packages/docs/components/card/index.md | 5 +- packages/docs/components/check-box/index.md | 83 +- packages/docs/components/divider/index.md | 24 +- .../docs/components/input-number/index.md | 9 +- packages/docs/components/input/index.md | 61 +- packages/docs/components/popover/index.md | 157 +- packages/docs/components/radio/index.md | 72 +- packages/docs/components/rate/index.md | 24 +- packages/docs/components/slider/index.md | 99 +- packages/docs/components/status/index.md | 45 +- packages/docs/components/tabs/index.md | 60 +- packages/docs/components/timeline/index.md | 29 +- packages/docs/components/tooltip/index.md | 125 +- packages/docs/components/tree/index.md | 50 +- packages/docs/env.d.ts | 5 + packages/docs/index.md | 12 +- packages/docs/package.json | 26 +- packages/docs/tsconfig.json | 15 + packages/docs/vite.config.ts | 2 +- packages/theme/package.json | 12 +- packages/theme/themes/dark.ts | 36 +- packages/theme/themes/light.ts | 36 +- pnpm-lock.yaml | 6980 +++++++---------- pnpm-workspace.yaml | 14 + vite.config.ts | 71 + 115 files changed, 4139 insertions(+), 6090 deletions(-) create mode 100644 .vite-hooks/pre-commit create mode 100644 AGENTS.md delete mode 100644 eslint.config.mjs create mode 100644 packages/ccui/env.d.ts create mode 100644 packages/docs/env.d.ts create mode 100644 packages/docs/tsconfig.json create mode 100644 vite.config.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5ea6029f..44754869 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,6 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100644 index 00000000..a5186b8b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +cd "$(git rev-parse --show-toplevel)" && vp staged diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ef041769 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ + + +# Using Vite+, the Unified Toolchain for the Web + +This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. + +Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/. + +## Review Checklist + +- [ ] Run `vp install` after pulling remote changes and before getting started. +- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. +- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run @@ -32,10 +32,7 @@ export default defineComponent({ - +
默认按钮 - - 主要按钮 - - - 成功按钮 - - - 警告按钮 - - - 危险按钮 - - - 信息按钮 - + 主要按钮 + 成功按钮 + 警告按钮 + 危险按钮 + 信息按钮
``` @@ -38,15 +28,9 @@ ```vue ``` @@ -60,13 +44,9 @@ ```vue ``` @@ -80,12 +60,8 @@ ```vue ``` @@ -109,11 +85,7 @@ ### Button3DType ```ts -export type Button3DType = 'primary' - | 'success' - | 'warning' - | 'danger' - | 'info' +export type Button3DType = 'primary' | 'success' | 'warning' | 'danger' | 'info' ``` ### Button3DSizeType diff --git a/packages/docs/components/button/index.md b/packages/docs/components/button/index.md index 16fe84af..82719538 100644 --- a/packages/docs/components/button/index.md +++ b/packages/docs/components/button/index.md @@ -17,31 +17,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -66,32 +54,20 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -115,33 +91,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -166,21 +128,15 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -204,59 +160,98 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: diff --git a/packages/docs/components/card/index.md b/packages/docs/components/card/index.md index aa278a66..da1d5263 100644 --- a/packages/docs/components/card/index.md +++ b/packages/docs/components/card/index.md @@ -13,7 +13,7 @@ ```vue @@ -36,8 +36,7 @@ export default { - + ``` ::: diff --git a/packages/docs/components/check-box/index.md b/packages/docs/components/check-box/index.md index 8031988e..8513611d 100644 --- a/packages/docs/components/check-box/index.md +++ b/packages/docs/components/check-box/index.md @@ -45,7 +45,7 @@ export default defineComponent({ checked6, checkBoxBeforeChange, } - } + }, }) @@ -54,12 +54,8 @@ export default defineComponent({ {{ msg }} - - 禁用 check-box - - - 改变 icon 的颜色 - + 禁用 check-box + 改变 icon 的颜色 checkBoxChange 事件, 关联下方beforeChange的切换状态 @@ -69,9 +65,7 @@ export default defineComponent({ - + ``` ::: @@ -104,9 +98,9 @@ export default defineComponent({ checkedList, checkBoxChange, canChange, - checkBoxBeforeChange + checkBoxBeforeChange, } - } + }, }) @@ -115,75 +109,45 @@ export default defineComponent({

基础示例

- - 北京 - - - 上海 - - - 广州 - + 北京 + 上海 + 广州

禁用

- - 北京 - - - 上海 - - - 广州 - + 北京 + 上海 + 广州

横向排列

- - 北京 - - - 上海 - - - 广州 - + 北京 + 上海 + 广州

checkBoxChange 和 color 颜色

- - 北京 - - - 上海 - - - 广州 - + 北京 + 上海 + 广州

beforeChange (选中上海可以切换) {{ canChange ? '可以' : '不可以' }}切换

- - 北京 - - - 上海 - - - 广州 - + 北京 + 上海 + 广州
@@ -225,10 +189,7 @@ export type LabelType = string | number | boolean ### BeforeChangeType ```ts -export type BeforeChangeType = ( - isChecked: boolean, - v: string -) => boolean | Promise +export type BeforeChangeType = (isChecked: boolean, v: string) => boolean | Promise ``` ## CheckBox插槽 diff --git a/packages/docs/components/divider/index.md b/packages/docs/components/divider/index.md index 88c21799..f22ecdc2 100644 --- a/packages/docs/components/divider/index.md +++ b/packages/docs/components/divider/index.md @@ -17,9 +17,9 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return { - msg: 'Divider 分隔线 组件文档示例' + msg: 'Divider 分隔线 组件文档示例', } - } + }, }) @@ -38,24 +38,16 @@ export default defineComponent({ 北京 在左侧的自定义文案 - - 上海 - + 上海 在右侧的自定义文案 - - 广州 - + 广州 文案的颜色 - - 广州 - + 广州 文案的背景颜色 - - 广州 - + 广州 垂直的分隔线
@@ -68,9 +60,7 @@ export default defineComponent({
- + ``` ::: diff --git a/packages/docs/components/input-number/index.md b/packages/docs/components/input-number/index.md index c1899693..e8b1fa3c 100644 --- a/packages/docs/components/input-number/index.md +++ b/packages/docs/components/input-number/index.md @@ -26,8 +26,7 @@ const value = ref(0) - + ``` ::: @@ -108,7 +107,7 @@ const value = ref(0) - + ``` ::: @@ -85,7 +83,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -95,8 +93,7 @@ export default defineComponent({ - + ``` ::: @@ -113,9 +110,9 @@ export default defineComponent({ setup() { const value = ref('只读内容') return { - value + value, } - } + }, }) @@ -125,8 +122,7 @@ export default defineComponent({ - + ``` ::: @@ -143,9 +139,9 @@ export default defineComponent({ setup() { const value = ref('可以清空的内容') return { - value + value, } - } + }, }) @@ -155,8 +151,7 @@ export default defineComponent({ - + ``` ::: @@ -173,9 +168,9 @@ export default defineComponent({ setup() { const value = ref('') return { - value + value, } - } + }, }) @@ -186,8 +181,7 @@ export default defineComponent({ - + ``` ::: @@ -204,9 +198,9 @@ export default defineComponent({ setup() { const value = ref('') return { - value + value, } - } + }, }) @@ -218,8 +212,7 @@ export default defineComponent({ - + ``` ::: @@ -255,14 +248,14 @@ export type InputSize = 'large' | 'default' | 'small' ## Input事件 -| 事件名 | 参数 | 说明 | -| -------------- | ----- | -------------------------------- | +| 事件名 | 参数 | 说明 | +| ----------------- | ----- | -------------------------------- | | update:modelValue | value | 绑定值改变时触发(v-model 事件) | -| input | value | 输入框值改变时触发 | -| change | value | 输入框值改变并失去焦点时触发 | -| focus | event | 输入框获得焦点时触发 | -| blur | event | 输入框失去焦点时触发 | -| clear | -- | 点击清空图标时触发 | +| input | value | 输入框值改变时触发 | +| change | value | 输入框值改变并失去焦点时触发 | +| focus | event | 输入框获得焦点时触发 | +| blur | event | 输入框失去焦点时触发 | +| clear | -- | 点击清空图标时触发 | ## Input插槽 diff --git a/packages/docs/components/popover/index.md b/packages/docs/components/popover/index.md index bf320f28..44948c8b 100644 --- a/packages/docs/components/popover/index.md +++ b/packages/docs/components/popover/index.md @@ -21,22 +21,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: @@ -54,22 +51,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: @@ -87,7 +81,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -100,20 +94,15 @@ export default defineComponent({ - - 自定义内容 - + 自定义内容 - + ``` ::: @@ -131,7 +120,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -141,19 +130,13 @@ export default defineComponent({
- - top-start - + top-start - - top - + top - - top-end - + top-end
@@ -161,37 +144,25 @@ export default defineComponent({
- - left-start - + left-start - - left - + left - - left-end - + left-end
- - right-start - + right-start - - right - + right - - right-end - + right-end
@@ -199,19 +170,13 @@ export default defineComponent({
- - bottom-start - + bottom-start - - bottom - + bottom - - bottom-end - + bottom-end
@@ -274,24 +239,19 @@ import { defineComponent, ref } from 'vue' export default defineComponent({ setup() { const visible = ref(false) - const toggle = () => visible.value = !visible.value + const toggle = () => (visible.value = !visible.value) return { visible, - toggle + toggle, } - } + }, }) - + ``` ::: @@ -318,26 +277,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: @@ -369,23 +321,16 @@ export default defineComponent({ triggerRef, visible, handleShow, - handleHide + handleHide, } - } + }, }) - - 删除文件 - + 删除文件 @@ -515,27 +454,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: diff --git a/packages/docs/components/radio/index.md b/packages/docs/components/radio/index.md index 54dc73f7..adbe01bf 100644 --- a/packages/docs/components/radio/index.md +++ b/packages/docs/components/radio/index.md @@ -24,25 +24,17 @@ export default defineComponent({ return { radioActive, - beforeChange + beforeChange, } - } + }, }) ``` @@ -72,9 +64,9 @@ export default defineComponent({ info, checkedRadio, checkedRadio1, - beforeChange + beforeChange, } - } + }, }) @@ -83,60 +75,36 @@ export default defineComponent({

基础示例

- - 上海 - - - 北京 - - - 禁止切换 - - - 广州 - + 上海 + 北京 + 禁止切换 + 广州

全部禁用

- - 上海 - - - 北京 - + 上海 + 北京

column 排列

- - 上海 - - - 北京 - - - 广州 - + 上海 + 北京 + 广州

row 排列

- - 上海 - - - 北京 - - - 广州 - + 上海 + 北京 + 广州
diff --git a/packages/docs/components/rate/index.md b/packages/docs/components/rate/index.md index caaca9cd..0f33bb48 100644 --- a/packages/docs/components/rate/index.md +++ b/packages/docs/components/rate/index.md @@ -75,9 +75,7 @@ export default { ``` @@ -142,30 +140,16 @@ export default { diff --git a/packages/docs/components/slider/index.md b/packages/docs/components/slider/index.md index f4f56e8e..a7396fcc 100644 --- a/packages/docs/components/slider/index.md +++ b/packages/docs/components/slider/index.md @@ -23,9 +23,9 @@ export default defineComponent({ const value = ref(50) return { - value + value, } - } + }, }) @@ -36,8 +36,7 @@ export default defineComponent({ - + ``` ::: @@ -57,9 +56,9 @@ export default defineComponent({ const range = ref([20, 80]) return { - range + range, } - } + }, }) @@ -70,8 +69,7 @@ export default defineComponent({ - + ``` ::: @@ -91,9 +89,9 @@ export default defineComponent({ const value = ref(30) return { - value + value, } - } + }, }) @@ -103,8 +101,7 @@ export default defineComponent({ - + ``` ::: @@ -124,9 +121,9 @@ export default defineComponent({ const value = ref(40) return { - value + value, } - } + }, }) @@ -137,8 +134,7 @@ export default defineComponent({ - + ``` ::: @@ -158,9 +154,9 @@ export default defineComponent({ const value = ref(40) return { - value + value, } - } + }, }) @@ -170,8 +166,7 @@ export default defineComponent({ - + ``` ::: @@ -193,14 +188,14 @@ export default defineComponent({ 0: '0°C', 26: '26°C', 37: '37°C', - 50: '50°C' + 50: '50°C', } return { value, - marks + marks, } - } + }, }) @@ -210,8 +205,7 @@ export default defineComponent({ - + ``` ::: @@ -233,9 +227,9 @@ export default defineComponent({ const value = ref(50) return { - value + value, } - } + }, }) @@ -246,8 +240,7 @@ export default defineComponent({ - + ``` ::: @@ -265,9 +258,9 @@ export default defineComponent({ const range = ref([20, 80]) return { - range + range, } - } + }, }) @@ -278,8 +271,7 @@ export default defineComponent({ - + ``` ::: @@ -306,9 +298,9 @@ export default defineComponent({ return { value1, value2, - formatTooltip + formatTooltip, } - } + }, }) @@ -324,8 +316,7 @@ export default defineComponent({ - + ``` ::: @@ -351,9 +342,9 @@ export default defineComponent({ value1, value2, value3, - value4 + value4, } - } + }, }) @@ -373,8 +364,7 @@ export default defineComponent({ - + ``` ::: @@ -398,9 +388,9 @@ export default defineComponent({ return { value1, value2, - value3 + value3, } - } + }, }) @@ -417,8 +407,7 @@ export default defineComponent({ - + ``` ::: @@ -438,9 +427,9 @@ export default defineComponent({ const value = ref(50) return { - value + value, } - } + }, }) @@ -450,8 +439,7 @@ export default defineComponent({ - + ``` ::: @@ -476,24 +464,19 @@ export default defineComponent({ return { value, - formatValueText + formatValueText, } - } + }, }) - + ``` ::: diff --git a/packages/docs/components/status/index.md b/packages/docs/components/status/index.md index 6797ee81..dc23748c 100644 --- a/packages/docs/components/status/index.md +++ b/packages/docs/components/status/index.md @@ -17,40 +17,24 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return { - msg: 'Status 状态 组件文档示例' + msg: 'Status 状态 组件文档示例', } - } + }, }) - + ``` ::: @@ -72,12 +56,5 @@ export default defineComponent({ ### IStatusType ```ts -type IStatusType - = | 'success' - | 'error' - | 'initial' - | 'warning' - | 'waiting' - | 'running' - | 'invalid' +type IStatusType = 'success' | 'error' | 'initial' | 'warning' | 'waiting' | 'running' | 'invalid' ``` diff --git a/packages/docs/components/tabs/index.md b/packages/docs/components/tabs/index.md index 098cd7af..d08ada0a 100644 --- a/packages/docs/components/tabs/index.md +++ b/packages/docs/components/tabs/index.md @@ -18,9 +18,9 @@ export default defineComponent({ setup() { const activeTab = ref('1') return { - activeTab + activeTab, } - } + }, }) @@ -32,9 +32,7 @@ export default defineComponent({ 人生就是一辆开往坟墓的列车,路途上会有很多站,很难有人可以自始至终地陪你走完。当陪你的人要下车时,即使不舍也该心存感激,然后挥手道别。人生就是如此,没有谁会一直陪伴谁,我们应该怀着感恩之心,和过去的好友告别。毕竟曾经在我们人生某一个艰难的时刻,她们的确给过我们温暖。 - - V我50 - + V我50 @@ -63,9 +61,9 @@ export default defineComponent({ setup() { const activeTab = ref('1') return { - activeTab + activeTab, } - } + }, }) @@ -77,9 +75,7 @@ export default defineComponent({ 人生就是一辆开往坟墓的列车,路途上会有很多站,很难有人可以自始至终地陪你走完。当陪你的人要下车时,即使不舍也该心存感激,然后挥手道别。人生就是如此,没有谁会一直陪伴谁,我们应该怀着感恩之心,和过去的好友告别。毕竟曾经在我们人生某一个艰难的时刻,她们的确给过我们温暖。 - - V我100别问为什么,问就是有中间商 - + V我100别问为什么,问就是有中间商 @@ -108,9 +104,9 @@ export default defineComponent({ setup() { const activeTab = ref('1') return { - activeTab + activeTab, } - } + }, }) @@ -153,18 +149,16 @@ export default defineComponent({ setup() { const activeTab = ref('1') return { - activeTab + activeTab, } - } + }, }) - + ``` ::: @@ -59,7 +58,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -82,8 +81,7 @@ export default defineComponent({ - + ``` ::: @@ -101,7 +99,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -130,8 +128,7 @@ export default defineComponent({ - + ``` ::: @@ -149,7 +146,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -172,8 +169,7 @@ export default defineComponent({ - + ``` ::: @@ -191,7 +187,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -200,7 +196,9 @@ export default defineComponent({ @@ -213,8 +211,7 @@ export default defineComponent({ - + ``` ::: diff --git a/packages/docs/components/tooltip/index.md b/packages/docs/components/tooltip/index.md index 39181aa9..d70e7d6d 100644 --- a/packages/docs/components/tooltip/index.md +++ b/packages/docs/components/tooltip/index.md @@ -21,22 +21,19 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) - + ``` ::: @@ -54,7 +51,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -62,72 +59,48 @@ export default defineComponent({
- - 上左 - + 上左 - - 上边 - + 上边 - - 上右 - + 上右
- - 左上 - + 左上 - - 左边 - + 左边 - - 左下 - + 左下
- - 右上 - + 右上 - - 右边 - + 右边 - - 右下 - + 右下
- - 下左 - + 下左 - - 下边 - + 下边 - - 下右 - + 下右
@@ -189,21 +162,17 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -230,7 +199,7 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -241,19 +210,11 @@ export default defineComponent({
多行信息
第二行信息
- - 多行文本 - + 多行文本 - - - HTML 内容 - + + HTML 内容 @@ -280,28 +241,22 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -328,22 +283,18 @@ import { defineComponent } from 'vue' export default defineComponent({ setup() { return {} - } + }, }) @@ -372,9 +323,9 @@ export default defineComponent({ const disabled = ref(false) return { - disabled + disabled, } - } + }, }) @@ -386,9 +337,7 @@ export default defineComponent({ - - 切换状态 - + 切换状态 @@ -416,18 +365,16 @@ export default defineComponent({ const visible = ref(false) return { - visible + visible, } - } + }, }) ``` @@ -75,18 +75,13 @@ export default { methods: { onClose(e) { console.log('alert closed', e) - } - } + }, + }, } ``` diff --git a/packages/docs/components/badge/index.md b/packages/docs/components/badge/index.md index 5292d2c6..5d0bea04 100644 --- a/packages/docs/components/badge/index.md +++ b/packages/docs/components/badge/index.md @@ -93,13 +93,13 @@ ```vue ``` diff --git a/packages/docs/components/breadcrumb/index.md b/packages/docs/components/breadcrumb/index.md index 58c7f4b6..1cf3bf03 100644 --- a/packages/docs/components/breadcrumb/index.md +++ b/packages/docs/components/breadcrumb/index.md @@ -45,10 +45,10 @@ export default defineComponent({ routes: [ { breadcrumbName: 'Home', href: '/' }, { breadcrumbName: 'Application List', path: '/list' }, - { breadcrumbName: 'Detail' } - ] + { breadcrumbName: 'Detail' }, + ], } - } + }, }) diff --git a/packages/docs/components/collapse/index.md b/packages/docs/components/collapse/index.md index 0b54cd75..fa87e47a 100644 --- a/packages/docs/components/collapse/index.md +++ b/packages/docs/components/collapse/index.md @@ -13,21 +13,15 @@ export default { setup() { const value = ref(['1']) return { value } - } + }, } ``` @@ -45,7 +39,7 @@ export default { setup() { const v = ref('a') return { v } - } + }, } diff --git a/packages/docs/components/config-provider/index.md b/packages/docs/components/config-provider/index.md index 80b00aae..2b741fec 100644 --- a/packages/docs/components/config-provider/index.md +++ b/packages/docs/components/config-provider/index.md @@ -13,9 +13,7 @@ ```vue ``` diff --git a/packages/docs/components/drawer/index.md b/packages/docs/components/drawer/index.md index f264d5e0..09e04bd5 100644 --- a/packages/docs/components/drawer/index.md +++ b/packages/docs/components/drawer/index.md @@ -13,7 +13,7 @@ export default { setup() { const visible = ref(false) return { visible } - } + }, } @@ -41,7 +41,7 @@ export default { const left = ref(false) const right = ref(false) return { top, bottom, left, right } - } + }, } diff --git a/packages/docs/components/empty/index.md b/packages/docs/components/empty/index.md index b580a5d7..14d998b7 100644 --- a/packages/docs/components/empty/index.md +++ b/packages/docs/components/empty/index.md @@ -26,9 +26,7 @@ ```vue ``` diff --git a/packages/docs/components/float-button/index.md b/packages/docs/components/float-button/index.md index acc338af..ae8ff5de 100644 --- a/packages/docs/components/float-button/index.md +++ b/packages/docs/components/float-button/index.md @@ -9,10 +9,7 @@ ```vue ``` diff --git a/packages/docs/components/grid/index.md b/packages/docs/components/grid/index.md index 994cf485..af75109b 100644 --- a/packages/docs/components/grid/index.md +++ b/packages/docs/components/grid/index.md @@ -15,24 +15,16 @@ @@ -58,14 +50,10 @@ @@ -81,24 +69,16 @@ diff --git a/packages/docs/components/image/index.md b/packages/docs/components/image/index.md index 97df8ce5..98049337 100644 --- a/packages/docs/components/image/index.md +++ b/packages/docs/components/image/index.md @@ -8,10 +8,7 @@ ```vue ``` diff --git a/packages/docs/components/layout/index.md b/packages/docs/components/layout/index.md index 2e1d83b4..d8083dd2 100644 --- a/packages/docs/components/layout/index.md +++ b/packages/docs/components/layout/index.md @@ -13,15 +13,9 @@ ```vue ``` @@ -38,9 +32,7 @@ Sider - - Main - + Main @@ -60,20 +52,16 @@ export default defineComponent({ setup() { const collapsed = ref(false) return { collapsed } - } + }, }) diff --git a/packages/docs/components/list/index.md b/packages/docs/components/list/index.md index 5a63d384..23859470 100644 --- a/packages/docs/components/list/index.md +++ b/packages/docs/components/list/index.md @@ -8,10 +8,7 @@ ```vue diff --git a/packages/docs/components/masonry/index.md b/packages/docs/components/masonry/index.md index 1cf6af92..021f6378 100644 --- a/packages/docs/components/masonry/index.md +++ b/packages/docs/components/masonry/index.md @@ -15,17 +15,15 @@ export default { data() { return { - heights: [80, 120, 60, 100, 140, 90, 70, 110, 130] + heights: [80, 120, 60, 100, 140, 90, 70, 110, 130], } - } + }, } @@ -49,9 +47,7 @@ export default { ```vue diff --git a/packages/docs/components/menu/index.md b/packages/docs/components/menu/index.md index 8b8d5b5f..f972644a 100644 --- a/packages/docs/components/menu/index.md +++ b/packages/docs/components/menu/index.md @@ -46,9 +46,7 @@ key: 'sub2', label: '导航二', type: 'submenu', - children: [ - { key: '3', label: '选项三' }, - ], + children: [{ key: '3', label: '选项三' }], }, ]" /> diff --git a/packages/docs/components/message/index.md b/packages/docs/components/message/index.md index a20a84a4..7f58034d 100644 --- a/packages/docs/components/message/index.md +++ b/packages/docs/components/message/index.md @@ -11,12 +11,22 @@ import { message } from 'vue3-ccui' export default { methods: { - showInfo() { message.info('这是一条信息') }, - showSuccess() { message.success('操作成功') }, - showWarning() { message.warning('警告') }, - showError() { message.error('出错了') }, - showLoading() { message.loading('加载中…') }, - } + showInfo() { + message.info('这是一条信息') + }, + showSuccess() { + message.success('操作成功') + }, + showWarning() { + message.warning('警告') + }, + showError() { + message.error('出错了') + }, + showLoading() { + message.loading('加载中…') + }, + }, } diff --git a/packages/docs/components/modal/index.md b/packages/docs/components/modal/index.md index 80c703b7..24dabb00 100644 --- a/packages/docs/components/modal/index.md +++ b/packages/docs/components/modal/index.md @@ -13,7 +13,7 @@ export default { setup() { const visible = ref(false) return { visible } - } + }, } @@ -39,15 +39,13 @@ export default { setup() { const v = ref(false) return { v } - } + }, } ``` diff --git a/packages/docs/components/notification/index.md b/packages/docs/components/notification/index.md index 5dd48cf1..ce1fd81f 100644 --- a/packages/docs/components/notification/index.md +++ b/packages/docs/components/notification/index.md @@ -16,8 +16,8 @@ export default { title: '操作成功', description: '您的操作已完成,可以继续下一步。', }) - } - } + }, + }, } @@ -39,8 +39,8 @@ export default { methods: { open(placement) { notification.info({ title: placement, description: '位置示例', placement }) - } - } + }, + }, } diff --git a/packages/docs/components/pagination/index.md b/packages/docs/components/pagination/index.md index 1071fa76..35c2c647 100644 --- a/packages/docs/components/pagination/index.md +++ b/packages/docs/components/pagination/index.md @@ -14,7 +14,7 @@ export default defineComponent({ setup() { const current = ref(1) return { current } - } + }, }) @@ -45,7 +45,7 @@ export default defineComponent({ ```vue ``` diff --git a/packages/docs/components/result/index.md b/packages/docs/components/result/index.md index eb8ee5aa..89ed914f 100644 --- a/packages/docs/components/result/index.md +++ b/packages/docs/components/result/index.md @@ -14,9 +14,7 @@ @@ -46,9 +44,7 @@ ``` @@ -46,7 +46,7 @@ export default defineComponent({ setup() { const loading = ref(true) return { loading } - } + }, }) @@ -57,10 +57,8 @@ export default defineComponent({

Further details about the context of this alert.

-
- - Toggle - +
+ Toggle ``` diff --git a/packages/docs/components/splitter/index.md b/packages/docs/components/splitter/index.md index 12565158..6c9795a4 100644 --- a/packages/docs/components/splitter/index.md +++ b/packages/docs/components/splitter/index.md @@ -14,14 +14,10 @@ @@ -37,14 +33,10 @@ diff --git a/packages/docs/components/statistic/index.md b/packages/docs/components/statistic/index.md index f6823d24..9fb9a715 100644 --- a/packages/docs/components/statistic/index.md +++ b/packages/docs/components/statistic/index.md @@ -27,7 +27,7 @@ export default { setup() { const deadline = computed(() => Date.now() + 1000 * 60 * 60 * 24) return { deadline } - } + }, } diff --git a/packages/docs/components/steps/index.md b/packages/docs/components/steps/index.md index 1d2e57ec..c6463f2c 100644 --- a/packages/docs/components/steps/index.md +++ b/packages/docs/components/steps/index.md @@ -27,15 +27,7 @@ ```vue ``` diff --git a/packages/docs/components/switch/index.md b/packages/docs/components/switch/index.md index 438bddfc..384c15c9 100644 --- a/packages/docs/components/switch/index.md +++ b/packages/docs/components/switch/index.md @@ -19,7 +19,7 @@ export default defineComponent({ setup() { const v = ref(true) return { v } - } + }, }) diff --git a/packages/docs/components/tag/index.md b/packages/docs/components/tag/index.md index 2b364d6a..32a06516 100644 --- a/packages/docs/components/tag/index.md +++ b/packages/docs/components/tag/index.md @@ -14,21 +14,11 @@ ```vue ``` @@ -41,39 +31,17 @@ ```vue ``` @@ -92,17 +60,13 @@ export default defineComponent({ setup() { const visible = ref(true) return { visible } - } + }, }) ``` @@ -114,15 +78,9 @@ export default defineComponent({ ```vue ``` diff --git a/packages/docs/components/typography/index.md b/packages/docs/components/typography/index.md index 56e1d5f7..52c839b9 100644 --- a/packages/docs/components/typography/index.md +++ b/packages/docs/components/typography/index.md @@ -16,22 +16,18 @@ Introduction - In the process of internal desktop applications development, many different design specs and implementations would be involved. + In the process of internal desktop applications development, many different design specs and implementations would + be involved. - After massive project practice and summaries, Ant Design, a design language for background applications, is refined. + After massive project practice and summaries, Ant Design, a design language for background applications, is + refined. - - Guidelines and Resources - + Guidelines and Resources - - Ant Design - + Ant Design · - - A UI design language - + A UI design language @@ -47,54 +43,30 @@ ``` @@ -108,18 +80,10 @@ ```vue ``` diff --git a/packages/docs/components/watermark/index.md b/packages/docs/components/watermark/index.md index abd76c5d..c02fe968 100644 --- a/packages/docs/components/watermark/index.md +++ b/packages/docs/components/watermark/index.md @@ -9,9 +9,7 @@ ```vue ``` diff --git a/packages/theme/themes/dark.ts b/packages/theme/themes/dark.ts index a3eeedc9..c1f56c96 100644 --- a/packages/theme/themes/dark.ts +++ b/packages/theme/themes/dark.ts @@ -5,35 +5,35 @@ export default { 'global-bg-normal': '#141414', 'base-bg': '#141414', 'base-bg-dark': '#000000', - 'brand': '#1668dc', + brand: '#1668dc', 'brand-foil': '#111a2c', 'brand-hover': '#3c89e8', 'brand-active': '#1554ad', 'brand-active-focus': '#103e85', - 'contrast': '#dc4446', - 'text': 'rgba(255, 255, 255, 0.85)', + contrast: '#dc4446', + text: 'rgba(255, 255, 255, 0.85)', 'text-weak': 'rgba(255, 255, 255, 0.65)', 'aide-text': 'rgba(255, 255, 255, 0.45)', 'aide-text-stress': 'rgba(255, 255, 255, 0.65)', - 'placeholder': 'rgba(255, 255, 255, 0.25)', + placeholder: 'rgba(255, 255, 255, 0.25)', 'light-text': '#ffffff', 'dark-text': 'rgba(255, 255, 255, 0.85)', - 'link': '#1668dc', + link: '#1668dc', 'link-active': '#1554ad', 'link-light': '#3c89e8', 'link-light-active': '#65a9f3', - 'line': '#424242', + line: '#424242', 'dividing-line': '#303030', - 'block': '#1f1f1f', - 'area': '#1f1f1f', - 'danger': '#dc4446', - 'warning': '#d89614', - 'waiting': 'rgba(255, 255, 255, 0.45)', - 'success': '#49aa19', - 'info': '#1668dc', - 'initial': '#111a2c', - 'unavailable': '#1f1f1f', - 'shadow': 'rgba(0, 0, 0, 0.4)', + block: '#1f1f1f', + area: '#1f1f1f', + danger: '#dc4446', + warning: '#d89614', + waiting: 'rgba(255, 255, 255, 0.45)', + success: '#49aa19', + info: '#1668dc', + initial: '#111a2c', + unavailable: '#1f1f1f', + shadow: 'rgba(0, 0, 0, 0.4)', 'light-shadow': 'rgba(0, 0, 0, 0.2)', // 图标 'icon-text': 'rgba(255, 255, 255, 0.85)', @@ -73,12 +73,12 @@ export default { 'highlight-overlay': 'rgba(0, 0, 0, 0.45)', 'range-item-hover-bg': '#111a2c', // 按钮 - 'primary': '#1668dc', + primary: '#1668dc', 'primary-hover': '#3c89e8', 'primary-active': '#1554ad', 'contrast-hover': '#e86e6b', 'contrast-active': '#ad2d2c', - 'secondary': 'rgba(255, 255, 255, 0.65)', + secondary: 'rgba(255, 255, 255, 0.65)', // 状态 'danger-line': '#58181c', 'danger-bg': '#2a1215', diff --git a/packages/theme/themes/light.ts b/packages/theme/themes/light.ts index eca0b888..32a2cb0b 100644 --- a/packages/theme/themes/light.ts +++ b/packages/theme/themes/light.ts @@ -6,35 +6,35 @@ export default { 'global-bg-normal': '#ffffff', 'base-bg': '#ffffff', 'base-bg-dark': 'rgba(0, 0, 0, 0.85)', - 'brand': '#1677ff', + brand: '#1677ff', 'brand-foil': '#e6f4ff', 'brand-hover': '#4096ff', 'brand-active': '#0958d9', 'brand-active-focus': '#003eb3', - 'contrast': '#ff4d4f', - 'text': 'rgba(0, 0, 0, 0.88)', + contrast: '#ff4d4f', + text: 'rgba(0, 0, 0, 0.88)', 'text-weak': 'rgba(0, 0, 0, 0.65)', 'aide-text': 'rgba(0, 0, 0, 0.45)', 'aide-text-stress': 'rgba(0, 0, 0, 0.65)', - 'placeholder': 'rgba(0, 0, 0, 0.25)', + placeholder: 'rgba(0, 0, 0, 0.25)', 'light-text': '#ffffff', 'dark-text': 'rgba(0, 0, 0, 0.88)', - 'link': '#1677ff', + link: '#1677ff', 'link-active': '#0958d9', 'link-light': '#4096ff', 'link-light-active': '#69b1ff', - 'line': '#d9d9d9', + line: '#d9d9d9', 'dividing-line': '#f0f0f0', - 'block': '#ffffff', - 'area': '#fafafa', - 'danger': '#ff4d4f', - 'warning': '#faad14', - 'waiting': 'rgba(0, 0, 0, 0.45)', - 'success': '#52c41a', - 'info': '#1677ff', - 'initial': '#e6f4ff', - 'unavailable': '#f5f5f5', - 'shadow': 'rgba(0, 0, 0, 0.15)', + block: '#ffffff', + area: '#fafafa', + danger: '#ff4d4f', + warning: '#faad14', + waiting: 'rgba(0, 0, 0, 0.45)', + success: '#52c41a', + info: '#1677ff', + initial: '#e6f4ff', + unavailable: '#f5f5f5', + shadow: 'rgba(0, 0, 0, 0.15)', 'light-shadow': 'rgba(0, 0, 0, 0.06)', // 图标 'icon-text': 'rgba(0, 0, 0, 0.88)', @@ -74,12 +74,12 @@ export default { 'highlight-overlay': 'rgba(255, 255, 255, 0.8)', 'range-item-hover-bg': '#e6f4ff', // 按钮 - 'primary': '#1677ff', + primary: '#1677ff', 'primary-hover': '#4096ff', 'primary-active': '#0958d9', 'contrast-hover': '#ff7875', 'contrast-active': '#d9363e', - 'secondary': 'rgba(0, 0, 0, 0.65)', + secondary: 'rgba(0, 0, 0, 0.65)', // 状态 'danger-line': '#ffccc7', 'danger-bg': '#fff2f0', @@ -238,7 +238,8 @@ export default { 'line-height-heading-4': '1.4', 'line-height-heading-5': '1.5', // 字体族 - 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + 'font-family': + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 'font-family-code': 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace', // 控件高度 'control-height': '32px', @@ -246,9 +247,12 @@ export default { 'control-height-lg': '40px', 'control-height-xs': '16px', // 阴影 - 'box-shadow': '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', - 'box-shadow-secondary': '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', - 'box-shadow-tertiary': '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)', + 'box-shadow': + '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', + 'box-shadow-secondary': + '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)', + 'box-shadow-tertiary': + '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)', // 动效 'motion-duration-slow': '0.3s', 'motion-duration-mid': '0.2s', @@ -264,14 +268,14 @@ export default { 'padding-xxs': '4px', 'padding-xs': '8px', 'padding-sm': '12px', - 'padding': '16px', + padding: '16px', 'padding-md': '20px', 'padding-lg': '24px', 'padding-xl': '32px', 'margin-xxs': '4px', 'margin-xs': '8px', 'margin-sm': '12px', - 'margin': '16px', + margin: '16px', 'margin-md': '20px', 'margin-lg': '24px', 'margin-xl': '32px', diff --git a/vite.config.ts b/vite.config.ts index 5b663be7..ea4aff3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,9 @@ export default defineConfig({ typeAware: true, typeCheck: true, }, + rules: { + 'typescript/no-floating-promises': 'off', + }, env: { builtin: true, es2026: true, From 271db25a5dee7f037b502d317acda9d9bd1d9d25 Mon Sep 17 00:00:00 2001 From: zcj Date: Tue, 5 May 2026 00:02:18 +0800 Subject: [PATCH 008/277] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/commit-msg | 4 ---- .husky/pre-commit | 5 ----- 2 files changed, 9 deletions(-) delete mode 100755 .husky/commit-msg delete mode 100755 .husky/pre-commit diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index a789998f..00000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx --no-install commitlint --edit \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 2b7063d5..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx @ls-lint/ls-lint && pnpx lint-staged - From f97967bfbbd45f01a197016c9f1250af2f8275be Mon Sep 17 00:00:00 2001 From: zcj Date: Tue, 5 May 2026 02:03:47 +0800 Subject: [PATCH 009/277] fix: resolve docs demo package import --- packages/docs/.vitepress/config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 14eddc2c..085af32d 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -12,6 +12,16 @@ export default defineConfig({ lang: 'zh-CN', title: 'vue3-ccui', description: 'vue3-ccui 组件库', + vite: { + resolve: { + alias: [ + { + find: /^vue3-ccui$/, + replacement: fileURLToPath(new URL('../../ccui/ui/vue-ccui.ts', import.meta.url)), + }, + ], + }, + }, lastUpdated: true, ignoreDeadLinks: true, // 忽略死链接 head: [ From 7ffa5339b8f31727f11b03dc878c7f1debd6dadf Mon Sep 17 00:00:00 2001 From: zcj Date: Tue, 5 May 2026 03:09:51 +0800 Subject: [PATCH 010/277] style: align form controls with ant design --- .../ui/check-box/src/check-box-group.scss | 4 +- packages/ccui/ui/check-box/src/check-box.scss | 109 +++++++++++++++--- packages/ccui/ui/check-box/src/check-box.tsx | 2 +- packages/ccui/ui/radio/src/radio-group.scss | 4 +- packages/ccui/ui/radio/src/radio.scss | 91 ++++++++++++--- packages/ccui/ui/rate/src/rate.scss | 40 +++++-- packages/ccui/ui/result/src/result.scss | 5 + 7 files changed, 207 insertions(+), 48 deletions(-) diff --git a/packages/ccui/ui/check-box/src/check-box-group.scss b/packages/ccui/ui/check-box/src/check-box-group.scss index f983b600..d355b138 100644 --- a/packages/ccui/ui/check-box/src/check-box-group.scss +++ b/packages/ccui/ui/check-box/src/check-box-group.scss @@ -15,10 +15,8 @@ } .#{$cls-prefix}-check-box { - line-height: 28px; - &:not(:last-child) { - padding-right: 20px; + margin-inline-end: 8px; } } } diff --git a/packages/ccui/ui/check-box/src/check-box.scss b/packages/ccui/ui/check-box/src/check-box.scss index 71b97cfb..4c9e2c0b 100644 --- a/packages/ccui/ui/check-box/src/check-box.scss +++ b/packages/ccui/ui/check-box/src/check-box.scss @@ -1,13 +1,17 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-check-box { - display: flex; + display: inline-flex; align-items: center; - font-size: 16px; + min-height: 22px; + margin: 0; + padding: 0; + color: $ccui-color-text; + font-size: $ccui-font-size; font-weight: normal; - line-height: 1.5; - color: $ccui-text; + line-height: $ccui-line-height-base; cursor: pointer; + transition: color $ccui-motion-duration-mid $ccui-motion-ease-in-out; & label { overflow: hidden; @@ -15,10 +19,9 @@ white-space: nowrap; } - // 隐藏inout元素方便后边自定义 &__input { + position: absolute; z-index: -1; - display: none; width: 0; height: 0; overflow: hidden; @@ -28,28 +31,102 @@ &__icon { position: relative; - display: inline-block; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; width: 16px; height: 16px; - margin-right: 5px; - overflow: hidden; - line-height: 16px; + margin-inline-end: 8px; + color: $ccui-color-border; + line-height: 1; vertical-align: middle; user-select: none; - fill: $ccui-text; + transition: color $ccui-motion-duration-mid $ccui-motion-ease-in-out; + + svg { + display: none; + } + + &::before { + box-sizing: border-box; + width: 16px; + height: 16px; + content: ''; + background-color: $ccui-color-bg-container; + border: 1px solid currentColor; + border-radius: $ccui-border-radius-sm; + transition: + background-color $ccui-motion-duration-mid $ccui-motion-ease-in-out, + border-color $ccui-motion-duration-mid $ccui-motion-ease-in-out; + } + + &::after { + position: absolute; + top: 50%; + left: 50%; + box-sizing: border-box; + width: 5px; + height: 9px; + content: ''; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + transform: translate(-50%, -56%) rotate(45deg) scale(0); + opacity: 0; + transition: + transform $ccui-motion-duration-mid $ccui-motion-ease-in-out, + opacity $ccui-motion-duration-mid $ccui-motion-ease-in-out; + } + } + + &:not(.disabled):hover { + .#{$cls-prefix}-check-box__icon { + color: $ccui-color-primary; + } } &.active { - color: #7693f5; + color: $ccui-color-text; + + .#{$cls-prefix}-check-box__icon { + color: $ccui-color-primary; + + &::before { + background-color: currentColor; + border-color: currentColor; + } - span { - fill: #7693f5; + &::after { + transform: translate(-50%, -56%) rotate(45deg) scale(1); + opacity: 1; + } } } &.disabled { - color: gray; + color: $ccui-color-text-disabled; cursor: not-allowed; - fill: gray; + + .#{$cls-prefix}-check-box__icon { + color: $ccui-color-border-disabled; + + &::before { + background-color: $ccui-disabled-bg; + } + } + + &.active { + .#{$cls-prefix}-check-box__icon { + &::before { + background-color: $ccui-color-fill; + border-color: $ccui-color-border-disabled; + } + + &::after { + border-color: $ccui-color-text-disabled; + } + } + } } } diff --git a/packages/ccui/ui/check-box/src/check-box.tsx b/packages/ccui/ui/check-box/src/check-box.tsx index 511c069a..a5710b3c 100644 --- a/packages/ccui/ui/check-box/src/check-box.tsx +++ b/packages/ccui/ui/check-box/src/check-box.tsx @@ -31,7 +31,7 @@ export default defineComponent({ const iconColor = computed(() => { const color = checkBoxGroupInject?.color.value || props.color - return color ? `fill: ${color}` : '' + return color ? `color: ${color}; fill: ${color}` : '' }) // todo 带测试逻辑 diff --git a/packages/ccui/ui/radio/src/radio-group.scss b/packages/ccui/ui/radio/src/radio-group.scss index d95bd8e8..98e7426e 100644 --- a/packages/ccui/ui/radio/src/radio-group.scss +++ b/packages/ccui/ui/radio/src/radio-group.scss @@ -15,10 +15,8 @@ } .#{$cls-prefix}-radio { - line-height: 28px; - &:not(:last-child) { - padding-right: 20px; + margin-inline-end: 8px; } } } diff --git a/packages/ccui/ui/radio/src/radio.scss b/packages/ccui/ui/radio/src/radio.scss index 8f1b75fa..2655850a 100644 --- a/packages/ccui/ui/radio/src/radio.scss +++ b/packages/ccui/ui/radio/src/radio.scss @@ -1,19 +1,22 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-radio { - display: flex; + display: inline-flex; align-items: center; justify-content: flex-start; - font-size: 14px; + min-height: 22px; + margin: 0; + padding: 0; + color: $ccui-color-text; + font-size: $ccui-font-size; font-weight: normal; - line-height: 1.5; - color: $ccui-text; + line-height: $ccui-line-height-base; cursor: pointer; + transition: color $ccui-motion-duration-mid $ccui-motion-ease-in-out; - // 隐藏inout元素方便后边自定义 &__input { + position: absolute; z-index: -1; - display: none; width: 0; height: 0; overflow: hidden; @@ -23,28 +26,84 @@ &__icon { position: relative; - display: inline-block; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; width: 16px; height: 16px; - margin-right: 5px; - overflow: hidden; - line-height: 16px; + margin-inline-end: 8px; + color: $ccui-color-border; + line-height: 1; vertical-align: middle; user-select: none; - fill: $ccui-text; + transition: color $ccui-motion-duration-mid $ccui-motion-ease-in-out; + + svg { + display: none; + } + + &::before { + box-sizing: border-box; + width: 16px; + height: 16px; + content: ''; + background-color: $ccui-color-bg-container; + border: 1px solid currentColor; + border-radius: 50%; + transition: + border-color $ccui-motion-duration-mid $ccui-motion-ease-in-out, + box-shadow $ccui-motion-duration-mid $ccui-motion-ease-in-out; + } + + &::after { + position: absolute; + width: 8px; + height: 8px; + content: ''; + background-color: $ccui-color-primary; + border-radius: 50%; + transform: scale(0); + opacity: 0; + transition: + transform $ccui-motion-duration-mid $ccui-motion-ease-in-out, + opacity $ccui-motion-duration-mid $ccui-motion-ease-in-out; + } + } + + &:not(.disabled):hover { + .#{$cls-prefix}-radio__icon { + color: $ccui-color-primary; + } } &.active { - color: #7693f5; + color: $ccui-color-text; - span { - fill: #7693f5; + .#{$cls-prefix}-radio__icon { + color: $ccui-color-primary; + + &::after { + transform: scale(1); + opacity: 1; + } } } &.disabled { - color: gray; + color: $ccui-color-text-disabled; cursor: not-allowed; - fill: gray; + + .#{$cls-prefix}-radio__icon { + color: $ccui-color-border-disabled; + + &::before { + background-color: $ccui-disabled-bg; + } + + &::after { + background-color: $ccui-color-text-disabled; + } + } } } diff --git a/packages/ccui/ui/rate/src/rate.scss b/packages/ccui/ui/rate/src/rate.scss index 3f537ce5..5932bd28 100644 --- a/packages/ccui/ui/rate/src/rate.scss +++ b/packages/ccui/ui/rate/src/rate.scss @@ -2,7 +2,10 @@ .#{$cls-prefix}-rate { display: inline-flex; - align-content: center; + align-items: center; + color: $ccui-color-warning; + font-size: 20px; + line-height: 1; &--read-only { span { @@ -12,27 +15,46 @@ &__icon { position: relative; - width: 16px; - height: 16px; - margin-right: 4px; - color: #e6e6e6; + width: 20px; + height: 20px; + margin-inline-end: 8px; + color: $ccui-color-fill; cursor: pointer; - fill: #e6e6e6; + fill: currentColor; + transition: + color $ccui-motion-duration-mid $ccui-motion-ease-in-out, + transform $ccui-motion-duration-mid $ccui-motion-ease-in-out; span { position: absolute; top: 0; left: 0; + width: 20px; + height: 20px; overflow: hidden; } + + svg { + display: block; + width: 20px; + height: 20px; + fill: currentColor; + } + + &:hover { + transform: scale(1.1); + } } &--active { - color: #7693f5; - fill: #6a81ed; + color: $ccui-color-warning; + fill: currentColor; } &__info { - margin: 0; + margin-inline-start: 8px; + color: $ccui-color-text; + font-size: $ccui-font-size; + line-height: $ccui-line-height-base; } } diff --git a/packages/ccui/ui/result/src/result.scss b/packages/ccui/ui/result/src/result.scss index 290ed802..12ff9c24 100644 --- a/packages/ccui/ui/result/src/result.scss +++ b/packages/ccui/ui/result/src/result.scss @@ -5,10 +5,15 @@ text-align: center; &__icon { + display: flex; + align-items: center; + justify-content: center; margin-bottom: 24px; text-align: center; svg { + display: block; + flex: none; vertical-align: middle; } } From fc9a9a5facb4fc52a05e15b7a4554a0d2b7b3122 Mon Sep 17 00:00:00 2001 From: zcj Date: Tue, 5 May 2026 03:19:29 +0800 Subject: [PATCH 011/277] style: distinguish info button colors --- packages/ccui/ui/button-3d/src/button-3d.scss | 13 +++++--- packages/ccui/ui/button/src/button.scss | 33 +++++++++++++++---- .../docs/.vitepress/theme/styles/index.css | 7 ++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/ccui/ui/button-3d/src/button-3d.scss b/packages/ccui/ui/button-3d/src/button-3d.scss index 5e254003..b50e62f4 100644 --- a/packages/ccui/ui/button-3d/src/button-3d.scss +++ b/packages/ccui/ui/button-3d/src/button-3d.scss @@ -1,5 +1,8 @@ @use '../../style-var/index.scss' as *; +$button-3d-info-color: #909399; +$button-3d-info-active-color: #73767a; + .#{$cls-prefix}-button-3d { position: relative; background: transparent; @@ -100,15 +103,15 @@ .edge { background: linear-gradient( to left, - $ccui-color-primary-active 0%, - $ccui-color-primary-active 8%, - $ccui-color-primary-active 92%, - $ccui-color-primary-active 100% + $button-3d-info-active-color 0%, + $button-3d-info-active-color 8%, + $button-3d-info-active-color 92%, + $button-3d-info-active-color 100% ); } .front { - background: $ccui-color-info; + background: $button-3d-info-color; color: $ccui-color-text-light-solid; } } diff --git a/packages/ccui/ui/button/src/button.scss b/packages/ccui/ui/button/src/button.scss index 32e54315..22aff23a 100644 --- a/packages/ccui/ui/button/src/button.scss +++ b/packages/ccui/ui/button/src/button.scss @@ -1,5 +1,11 @@ @use '../../style-var/index.scss' as *; +$button-info-color: #909399; +$button-info-hover-color: #a6a9ad; +$button-info-active-color: #73767a; +$button-info-plain-bg: #f4f4f5; +$button-info-plain-border: #dcdfe6; + .#{$cls-prefix}-button { box-sizing: border-box; display: inline-flex; @@ -94,11 +100,16 @@ } &--info { - background-color: $ccui-color-info; + background-color: $button-info-color; &:hover { - background-color: $ccui-color-info-hover; - border-color: $ccui-color-info-hover; + background-color: $button-info-hover-color; + border-color: $button-info-hover-color; + } + + &:active { + background-color: $button-info-active-color; + border-color: $button-info-active-color; } } @@ -192,9 +203,19 @@ } &--plain-info { - color: $ccui-color-info; - background-color: $ccui-color-info-bg; - border-color: $ccui-color-info-border; + color: $button-info-color; + background-color: $button-info-plain-bg; + border-color: $button-info-plain-border; + + &:hover { + color: $button-info-hover-color; + border-color: $button-info-hover-color; + } + + &:active { + background-color: $button-info-color; + border-color: $button-info-color; + } } &--plain-warning { diff --git a/packages/docs/.vitepress/theme/styles/index.css b/packages/docs/.vitepress/theme/styles/index.css index bccf2153..b7d83302 100644 --- a/packages/docs/.vitepress/theme/styles/index.css +++ b/packages/docs/.vitepress/theme/styles/index.css @@ -7,6 +7,13 @@ display: table; } +.vp-doc + [class*='_example-showcase_'] + :where([class^='ccui-']:not([class*='__']), [class*=' ccui-']:not([class*='__'])) { + margin-inline-end: 8px; + margin-block-end: 8px; +} + .mt-10 { margin-top: 10px; } From 678473b052f62db65060d2015ee37b508f7251f6 Mon Sep 17 00:00:00 2001 From: zcj Date: Tue, 5 May 2026 13:09:01 +0800 Subject: [PATCH 012/277] feat: add icon and select components --- COMPONENTS_DIFF.md | 165 +++++----- packages/ccui/ui/icon/index.ts | 18 ++ packages/ccui/ui/icon/src/icon-registry.ts | 26 ++ packages/ccui/ui/icon/src/icon-types.ts | 54 ++++ packages/ccui/ui/icon/src/icon.scss | 37 +++ packages/ccui/ui/icon/src/icon.tsx | 66 ++++ packages/ccui/ui/icon/test/icon.test.ts | 70 +++++ packages/ccui/ui/select/index.ts | 17 ++ .../ui/select/src/composables/use-select.ts | 84 ++++++ packages/ccui/ui/select/src/select-types.ts | 64 ++++ packages/ccui/ui/select/src/select.scss | 208 +++++++++++++ packages/ccui/ui/select/src/select.tsx | 282 ++++++++++++++++++ packages/ccui/ui/select/test/select.test.ts | 201 +++++++++++++ packages/docs/components/icon/index.md | 63 ++++ packages/docs/components/select/index.md | 108 +++++++ 15 files changed, 1392 insertions(+), 71 deletions(-) create mode 100644 packages/ccui/ui/icon/index.ts create mode 100644 packages/ccui/ui/icon/src/icon-registry.ts create mode 100644 packages/ccui/ui/icon/src/icon-types.ts create mode 100644 packages/ccui/ui/icon/src/icon.scss create mode 100644 packages/ccui/ui/icon/src/icon.tsx create mode 100644 packages/ccui/ui/icon/test/icon.test.ts create mode 100644 packages/ccui/ui/select/index.ts create mode 100644 packages/ccui/ui/select/src/composables/use-select.ts create mode 100644 packages/ccui/ui/select/src/select-types.ts create mode 100644 packages/ccui/ui/select/src/select.scss create mode 100644 packages/ccui/ui/select/src/select.tsx create mode 100644 packages/ccui/ui/select/test/select.test.ts create mode 100644 packages/docs/components/icon/index.md create mode 100644 packages/docs/components/select/index.md diff --git a/COMPONENTS_DIFF.md b/COMPONENTS_DIFF.md index f9214f52..c230a315 100644 --- a/COMPONENTS_DIFF.md +++ b/COMPONENTS_DIFF.md @@ -1,90 +1,91 @@ # vue3-ccui 与 Ant Design 组件对比清单 > 数据来源:Ant Design 官方组件总览(基于 v6.3.7 口径,共 71 个官方组件)。 -> 当前项目组件:56 个目录级组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 -> 更新时间:2026-05-04 23:30,根据 `2026-05-04-233025-componentsdiffmd.txt` 和当前 `packages/ccui/ui` 目录更新。 - -## 一、已覆盖组件(56 项) - -| ccui 组件 | Ant Design 对应 | 分类 | 状态 | -| --------------------- | ----------------------- | --------------- | ------ | -| Affix | Affix 固钉 | 其他 | 已完成 | -| Alert | Alert 警告提示 | 反馈 | 已完成 | -| Anchor | Anchor 锚点 | 导航 | 已完成 | -| Avatar | Avatar 头像 | 数据展示 | 已完成 | -| Badge | Badge 徽标数 | 数据展示 | 已完成 | -| Breadcrumb | Breadcrumb 面包屑 | 导航 | 已完成 | -| Button | Button 按钮 | 通用 | 已完成 | -| Button3D | 项目特色组件 | 通用 | 已完成 | -| Calendar | Calendar 日历 | 数据展示 | 已完成 | -| Card | Card 卡片 | 数据展示 | 已完成 | -| CheckBox | Checkbox 多选框 | 数据录入 | 已完成 | -| Collapse | Collapse 折叠面板 | 数据展示 | 已完成 | -| ConfigProvider | ConfigProvider 全局配置 | 其他 | 已完成 | -| Descriptions | Descriptions 描述列表 | 数据展示 | 已完成 | -| Divider | Divider 分割线 | 布局 | 已完成 | -| Drawer | Drawer 抽屉 | 反馈 | 已完成 | -| Dropdown | Dropdown 下拉菜单 | 导航 | 已完成 | -| Empty | Empty 空状态 | 数据展示 | 已完成 | -| Flex | Flex 弹性布局 | 布局 | 已完成 | -| FloatButton / BackTop | FloatButton 悬浮按钮 | 通用 | 已完成 | -| Grid | Grid 栅格 | 布局 | 已完成 | -| Image | Image 图片 | 数据展示 | 已完成 | -| Input | Input 输入框 | 数据录入 | 已完成 | -| InputNumber | InputNumber 数字输入框 | 数据录入 | 已完成 | -| Layout | Layout 布局 | 布局 | 已完成 | -| List | List 列表 | 数据展示 | 已完成 | -| Masonry | 瀑布流布局 | 布局 | 已完成 | -| Menu | Menu 导航菜单 | 导航 | 已完成 | -| Message | Message 全局提示 | 反馈 | 已完成 | -| Modal | Modal 对话框 | 反馈 | 已完成 | -| Notification | Notification 通知 | 反馈 | 已完成 | -| Pagination | Pagination 分页 | 导航 | 已完成 | -| Popconfirm | Popconfirm 气泡确认框 | 反馈 | 已完成 | -| Popover | Popover 气泡卡片 | 反馈 | 已完成 | -| Progress | Progress 进度条 | 反馈 | 已完成 | -| Radio | Radio 单选框 | 数据录入 | 已完成 | -| Rate | Rate 评分 | 数据录入 | 已完成 | -| Result | Result 结果 | 反馈 | 已完成 | -| Segmented | Segmented 分段控制器 | 数据展示 | 已完成 | -| Skeleton | Skeleton 骨架屏 | 反馈 | 已完成 | -| Slider | Slider 滑动输入条 | 数据录入 | 已完成 | -| Space | Space 间距 | 布局 | 已完成 | -| Spin | Spin 加载中 | 反馈 | 已完成 | -| Splitter | Splitter 分隔面板 | 布局 | 已完成 | -| Statistic | Statistic 统计数值 | 数据展示 | 已完成 | -| Status | Tag 近似组件 | 通用 / 数据展示 | 已完成 | -| Steps | Steps 步骤条 | 导航 | 已完成 | -| Switch | Switch 开关 | 数据录入 | 已完成 | -| Tabs | Tabs 标签页 | 导航 | 已完成 | -| Tag | Tag 标签 | 数据展示 | 已完成 | -| Timeline | Timeline 时间轴 | 数据展示 | 已完成 | -| Tooltip | Tooltip 文字提示 | 反馈 | 已完成 | -| Tree | Tree 树形控件 | 数据展示 | 已完成 | -| Typography | Typography 排版 | 通用 | 已完成 | -| Util | 工具函数集合 | 其他 | 已完成 | -| Watermark | Watermark 水印 | 数据展示 | 已完成 | +> 当前项目组件:58 个目录级组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 +> 更新时间:2026-05-05 13:10,根据当前 `packages/ccui/ui` 目录、Icon / Select 新增实现和验证结果更新。 + +## 一、已覆盖组件(58 项) + +| ccui 组件 | Ant Design 对应 | 分类 | 状态 | +| --------------------- | ----------------------- | --------------- | -------- | +| Affix | Affix 固钉 | 其他 | 已完成 | +| Alert | Alert 警告提示 | 反馈 | 已完成 | +| Anchor | Anchor 锚点 | 导航 | 已完成 | +| Avatar | Avatar 头像 | 数据展示 | 已完成 | +| Badge | Badge 徽标数 | 数据展示 | 已完成 | +| Breadcrumb | Breadcrumb 面包屑 | 导航 | 已完成 | +| Button | Button 按钮 | 通用 | 已完成 | +| Button3D | 项目特色组件 | 通用 | 已完成 | +| Calendar | Calendar 日历 | 数据展示 | 已完成 | +| Card | Card 卡片 | 数据展示 | 已完成 | +| CheckBox | Checkbox 多选框 | 数据录入 | 已完成 | +| Collapse | Collapse 折叠面板 | 数据展示 | 已完成 | +| ConfigProvider | ConfigProvider 全局配置 | 其他 | 已完成 | +| Descriptions | Descriptions 描述列表 | 数据展示 | 已完成 | +| Divider | Divider 分割线 | 布局 | 已完成 | +| Drawer | Drawer 抽屉 | 反馈 | 已完成 | +| Dropdown | Dropdown 下拉菜单 | 导航 | 已完成 | +| Empty | Empty 空状态 | 数据展示 | 已完成 | +| Flex | Flex 弹性布局 | 布局 | 已完成 | +| FloatButton / BackTop | FloatButton 悬浮按钮 | 通用 | 已完成 | +| Grid | Grid 栅格 | 布局 | 已完成 | +| Icon | Icon 图标 | 通用 | 基础完成 | +| Image | Image 图片 | 数据展示 | 已完成 | +| Input | Input 输入框 | 数据录入 | 已完成 | +| InputNumber | InputNumber 数字输入框 | 数据录入 | 已完成 | +| Layout | Layout 布局 | 布局 | 已完成 | +| List | List 列表 | 数据展示 | 已完成 | +| Masonry | 瀑布流布局 | 布局 | 已完成 | +| Menu | Menu 导航菜单 | 导航 | 已完成 | +| Message | Message 全局提示 | 反馈 | 已完成 | +| Modal | Modal 对话框 | 反馈 | 已完成 | +| Notification | Notification 通知 | 反馈 | 已完成 | +| Pagination | Pagination 分页 | 导航 | 已完成 | +| Popconfirm | Popconfirm 气泡确认框 | 反馈 | 已完成 | +| Popover | Popover 气泡卡片 | 反馈 | 已完成 | +| Progress | Progress 进度条 | 反馈 | 已完成 | +| Radio | Radio 单选框 | 数据录入 | 已完成 | +| Rate | Rate 评分 | 数据录入 | 已完成 | +| Result | Result 结果 | 反馈 | 已完成 | +| Segmented | Segmented 分段控制器 | 数据展示 | 已完成 | +| Select | Select 选择器 | 数据录入 | 基础完成 | +| Skeleton | Skeleton 骨架屏 | 反馈 | 已完成 | +| Slider | Slider 滑动输入条 | 数据录入 | 已完成 | +| Space | Space 间距 | 布局 | 已完成 | +| Spin | Spin 加载中 | 反馈 | 已完成 | +| Splitter | Splitter 分隔面板 | 布局 | 已完成 | +| Statistic | Statistic 统计数值 | 数据展示 | 已完成 | +| Status | Tag 近似组件 | 通用 / 数据展示 | 已完成 | +| Steps | Steps 步骤条 | 导航 | 已完成 | +| Switch | Switch 开关 | 数据录入 | 已完成 | +| Tabs | Tabs 标签页 | 导航 | 已完成 | +| Tag | Tag 标签 | 数据展示 | 已完成 | +| Timeline | Timeline 时间轴 | 数据展示 | 已完成 | +| Tooltip | Tooltip 文字提示 | 反馈 | 已完成 | +| Tree | Tree 树形控件 | 数据展示 | 已完成 | +| Typography | Typography 排版 | 通用 | 已完成 | +| Util | 工具函数集合 | 其他 | 已完成 | +| Watermark | Watermark 水印 | 数据展示 | 已完成 | > 备注:`Status` 功能上接近 Ant Design 的 `Tag`,已有独立 `Tag` 后,建议后续把 `Status` 视为别名兼容或逐步废弃。 +> `Select` 当前为基础可用版本,已覆盖单选、多选、搜索、清空、禁用、loading、空状态、tag 限制和基础键盘交互;尚未达到 Ant Design Select 的完整能力。 ## 二、缺失组件清单 -### 中等复杂度剩余(4 项) +### 中等复杂度剩余(3 项) | 组件 | 分类 | 复杂点 | 建议优先级 | | ---------------------- | -------- | ---------------------------------------- | ---------- | -| Icon 图标 | 通用 | SVG 图标体系、按需导出、尺寸/颜色继承 | P1 | | Carousel 走马灯 | 数据展示 | 自动播放、手势/键盘、循环与动画状态 | P2 | | QRCode 二维码 | 数据展示 | 需要二维码生成库、纠错级别、图标嵌入 | P2 | | ColorPicker 颜色选择器 | 数据录入 | 色板、HSV/RGB/HEX 转换、透明度、浮层交互 | P2 | -### 复杂组件(12 项) +### 复杂组件(11 项) | 组件 | 分类 | 复杂点 | 建议优先级 | | --------------------- | -------- | ---------------------------------------- | ---------- | | Form 表单 | 数据录入 | 字段管理、校验、联动、与所有录入组件耦合 | P0 | | Table 表格 | 数据展示 | 排序、筛选、固定列、展开行、虚拟滚动 | P0 | -| Select 选择器 | 数据录入 | 搜索、分组、多选、异步、虚拟列表 | P0 | | DatePicker 日期选择框 | 数据录入 | 日期面板、范围、时间联动、国际化 | P1 | | TimePicker 时间选择框 | 数据录入 | 滚轮选择、范围、禁用项 | P1 | | Cascader 级联选择 | 数据录入 | 多级联动、异步加载、搜索 | P1 | @@ -137,19 +138,41 @@ - Watermark 的 MutationObserver 仅监听 `childList`,避免 attributes 变更触发自递归。 - Image 预览使用 `Teleport`,并对 IntersectionObserver 做兼容兜底。 +### Batch 4:Icon 与 Select 基础版 + +已完成 2 项:Icon、Select。 + +关键能力: + +- Icon:支持注册表 API(`registerIcon` / `resolveIcon` / `unregisterIcon` / `clearIconRegistry`)、组件图标、插槽图标、font icon fallback、尺寸、颜色、旋转和 spin。 +- Select:支持单选、多选、filterable 搜索、clearable 清空、disabled、loading / empty 文案、maxTagCount、外部点击关闭、Escape 关闭、Enter / Arrow 键盘选择。 +- Select 已补充关键交互测试,从 6 个用例扩展到 15 个用例,覆盖单选、多选、清空、禁用、搜索、tag 限制、可见状态和键盘路径。 +- Select 修复项:option 点击冒泡导致单选关闭后重开、多选已有 tag 后无法继续搜索、键盘导航命中 disabled option、乱码默认文案/显示字符。 + +验证结果: + +- `vp check` 通过。 +- `vp test packages/ccui/ui/icon/test/icon.test.ts --environment jsdom` 通过。 +- `vp test packages/ccui/ui/select/test/select.test.ts --environment jsdom` 通过,15 个用例通过。 +- `vp run --filter docs docs:build` 通过;仍有 Grid / Tag 既有 Sass deprecation warning。 +- `vp test --environment jsdom` 全量仍失败,失败来自既有 TSX import-analysis 配置/解析问题,例如 `packages/ccui/ui/affix/src/affix.tsx`,不属于 Icon / Select 定向测试失败。 + +Select 剩余非完整对齐项: + +- 尚未支持 option group、自定义 option/tag render、remote search 约定、popup container / placement、fieldNames、虚拟列表、tags / allowCreate 模式、完整 ARIA 和 Form 校验状态集成。 + ## 四、后续任务规划 ### P0:补齐核心复杂组件 1. Form:先定义字段上下文、校验协议、FormItem 数据流,再接入 Input / Select / Checkbox / Radio / Switch 等录入组件。 2. Table:先交付基础列渲染、排序、筛选、分页联动,再拆固定列、展开行、虚拟滚动。 -3. Select:先实现单选/多选/搜索/远程 loading,再与 Form 校验状态打通。 +3. Select:在基础版上继续补 option group、自定义渲染、远程搜索协议、虚拟列表、popup 定位和 Form 校验状态集成。 ### P1:补齐高频录入和基础设施 -1. Icon:建立图标注册、按需导出、统一尺寸/颜色继承规则,为后续组件减少内联 SVG。 -2. DatePicker / TimePicker:建议先抽日期时间工具层,再做面板组件。 -3. Cascader / TreeSelect:复用 Tree 和 Select 的已完成能力。 +1. DatePicker / TimePicker:建议先抽日期时间工具层,再做面板组件。 +2. Cascader / TreeSelect:复用 Tree 和 Select 的已完成能力。 ### P2:增强展示与低频录入 diff --git a/packages/ccui/ui/icon/index.ts b/packages/ccui/ui/icon/index.ts new file mode 100644 index 00000000..1101dac1 --- /dev/null +++ b/packages/ccui/ui/icon/index.ts @@ -0,0 +1,18 @@ +import type { App } from 'vue' +import Icon from './src/icon' +import { clearIconRegistry, registerIcon, resolveIcon, unregisterIcon } from './src/icon-registry' + +Icon.install = function (app: App): void { + app.component(Icon.name!, Icon) +} + +export { clearIconRegistry, Icon, registerIcon, resolveIcon, unregisterIcon } + +export default { + title: 'Icon 图标', + category: '通用', + status: '20%', + install(app: App): void { + app.component(Icon.name!, Icon) + }, +} diff --git a/packages/ccui/ui/icon/src/icon-registry.ts b/packages/ccui/ui/icon/src/icon-registry.ts new file mode 100644 index 00000000..34a9aa54 --- /dev/null +++ b/packages/ccui/ui/icon/src/icon-registry.ts @@ -0,0 +1,26 @@ +import type { Component } from 'vue' + +const iconRegistry = new Map() + +function normalizeName(name: string) { + return name.trim() +} + +export function registerIcon(name: string, component: Component) { + iconRegistry.set(normalizeName(name), component) +} + +export function unregisterIcon(name: string) { + iconRegistry.delete(normalizeName(name)) +} + +export function clearIconRegistry() { + iconRegistry.clear() +} + +export function resolveIcon(name?: string) { + if (!name) { + return undefined + } + return iconRegistry.get(normalizeName(name)) +} diff --git a/packages/ccui/ui/icon/src/icon-types.ts b/packages/ccui/ui/icon/src/icon-types.ts new file mode 100644 index 00000000..de964879 --- /dev/null +++ b/packages/ccui/ui/icon/src/icon-types.ts @@ -0,0 +1,54 @@ +import type { Component, PropType } from 'vue' + +export type IconSize = number | string + +export interface IconProps { + name?: string + component?: Component + size?: IconSize + color?: string + rotate?: number + spin?: boolean + title?: string + ariaLabel?: string + prefixCls?: string +} + +export const iconProps = { + name: { + type: String, + default: '', + }, + component: { + type: Object as PropType, + default: undefined, + }, + size: { + type: [Number, String] as PropType, + default: undefined, + }, + color: { + type: String, + default: '', + }, + rotate: { + type: Number, + default: 0, + }, + spin: { + type: Boolean, + default: false, + }, + title: { + type: String, + default: '', + }, + ariaLabel: { + type: String, + default: '', + }, + prefixCls: { + type: String, + default: '', + }, +} as const diff --git a/packages/ccui/ui/icon/src/icon.scss b/packages/ccui/ui/icon/src/icon.scss new file mode 100644 index 00000000..8b33d229 --- /dev/null +++ b/packages/ccui/ui/icon/src/icon.scss @@ -0,0 +1,37 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + fill: currentColor; + line-height: 0; + transform: rotate(var(--ccui-icon-rotate, 0deg)); + transform-origin: center; + vertical-align: -0.125em; + + svg, + i { + display: block; + width: 1em; + height: 1em; + flex: none; + fill: currentColor; + stroke: currentColor; + } + + &--spin { + animation: ccui-icon-spin 1s linear infinite; + } +} + +@keyframes ccui-icon-spin { + from { + transform: rotate(var(--ccui-icon-rotate, 0deg)); + } + + to { + transform: rotate(calc(var(--ccui-icon-rotate, 0deg) + 1turn)); + } +} diff --git a/packages/ccui/ui/icon/src/icon.tsx b/packages/ccui/ui/icon/src/icon.tsx new file mode 100644 index 00000000..a6a0469b --- /dev/null +++ b/packages/ccui/ui/icon/src/icon.tsx @@ -0,0 +1,66 @@ +import type { CSSProperties } from 'vue' +import type { IconProps } from './icon-types' +import { computed, defineComponent, h, useAttrs } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { resolveIcon } from './icon-registry' +import { iconProps } from './icon-types' +import './icon.scss' + +export default defineComponent({ + name: 'CIcon', + inheritAttrs: false, + props: iconProps, + setup(props: IconProps, { slots }) { + const attrs = useAttrs() + const ns = useNamespace('icon') + + const iconPrefixCls = computed(() => props.prefixCls || 'ccui-icon') + const resolvedIcon = computed(() => props.component ?? resolveIcon(props.name)) + const hasNamedFontIcon = computed(() => !!props.name && !resolvedIcon.value) + + const iconStyle = computed(() => { + const style: CSSProperties & Record = {} + if (props.size !== undefined && props.size !== null && props.size !== '') { + style.fontSize = typeof props.size === 'number' ? `${props.size}px` : props.size + } + if (props.color) { + style.color = props.color + } + if (props.rotate !== undefined) { + style['--ccui-icon-rotate'] = `${props.rotate}deg` + } + return style + }) + + const iconCls = computed(() => ({ + [ns.b()]: true, + [ns.m('spin')]: props.spin, + [ns.m('font')]: hasNamedFontIcon.value, + [ns.m('svg')]: !!resolvedIcon.value || !!slots.default, + })) + + return () => { + const { class: attrClass, style: attrStyle, ...restAttrs } = attrs as Record + const IconComponent = resolvedIcon.value + const content = IconComponent + ? h(IconComponent as never) + : hasNamedFontIcon.value + ? h('i', { class: [iconPrefixCls.value, `${iconPrefixCls.value}-${props.name}`] }) + : slots.default?.() + + return h( + 'span', + { + ...restAttrs, + class: [attrClass, iconCls.value], + style: [attrStyle as CSSProperties, iconStyle.value], + role: props.title || props.ariaLabel ? 'img' : undefined, + 'aria-hidden': props.title || props.ariaLabel ? undefined : 'true', + 'aria-label': props.ariaLabel || props.title || undefined, + title: props.title || undefined, + }, + content, + ) + } + }, +}) diff --git a/packages/ccui/ui/icon/test/icon.test.ts b/packages/ccui/ui/icon/test/icon.test.ts new file mode 100644 index 00000000..02bf50ae --- /dev/null +++ b/packages/ccui/ui/icon/test/icon.test.ts @@ -0,0 +1,70 @@ +import { defineComponent, h } from 'vue' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it } from 'vite-plus/test' +import { clearIconRegistry, Icon, registerIcon } from '../index' +import { useNamespace } from '../../shared/hooks/use-namespace' + +const ns = useNamespace('icon', true) + +const SearchIcon = defineComponent({ + name: 'SearchIcon', + setup() { + return () => + h('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true' }, [ + h('path', { d: 'M10 4a6 6 0 1 0 3.87 10.59L20 20.7 20.7 20 14.59 13.87A6 6 0 0 0 10 4z' }), + ]) + }, +}) + +beforeEach(() => { + clearIconRegistry() +}) + +describe('icon', () => { + it('renders registered component icon by name', () => { + registerIcon('search', SearchIcon) + + const wrapper = mount(Icon, { + props: { name: 'search' }, + }) + + expect(wrapper.find('svg').exists()).toBe(true) + expect(wrapper.find(ns.b()).exists()).toBe(true) + }) + + it('applies size, color and rotate styles', () => { + const wrapper = mount(Icon, { + props: { + size: 20, + color: '#1677ff', + rotate: 90, + }, + slots: { + default: '', + }, + }) + + expect(wrapper.attributes('style')).toContain('font-size: 20px') + expect(wrapper.attributes('style')).toContain('color: rgb(22, 119, 255)') + expect(wrapper.attributes('style')).toContain('--ccui-icon-rotate: 90deg') + }) + + it('renders font icon class when a named icon is missing from registry', () => { + const wrapper = mount(Icon, { + props: { name: 'edit' }, + }) + + expect(wrapper.find('i').classes()).toContain('ccui-icon-edit') + }) + + it('supports spin class', () => { + const wrapper = mount(Icon, { + props: { spin: true }, + slots: { + default: '', + }, + }) + + expect(wrapper.classes()).toContain(ns.m('spin').substring(1)) + }) +}) diff --git a/packages/ccui/ui/select/index.ts b/packages/ccui/ui/select/index.ts new file mode 100644 index 00000000..bf905763 --- /dev/null +++ b/packages/ccui/ui/select/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Select from './src/select' + +Select.install = function (app: App): void { + app.component(Select.name!, Select) +} + +export { Select } + +export default { + title: 'Select', + category: 'Data Entry', + status: '35%', + install(app: App): void { + app.component(Select.name!, Select) + }, +} diff --git a/packages/ccui/ui/select/src/composables/use-select.ts b/packages/ccui/ui/select/src/composables/use-select.ts new file mode 100644 index 00000000..4f146e13 --- /dev/null +++ b/packages/ccui/ui/select/src/composables/use-select.ts @@ -0,0 +1,84 @@ +import type { ComputedRef, Ref } from 'vue' +import type { SelectModelValue, SelectOption, SelectProps, SelectRawValue } from '../select-types' +import { computed } from 'vue' + +function toValueArray(value: SelectModelValue) { + if (Array.isArray(value)) { + return value + } + if (value === undefined || value === '') { + return [] + } + return [value] +} + +export function useSelect( + props: SelectProps, + searchText: Ref, + emit: (event: 'update:modelValue' | 'change', value: SelectModelValue) => void, +) { + const selectedValues = computed(() => toValueArray(props.modelValue)) + + const selectedOptions = computed(() => + selectedValues.value + .map((value) => props.options.find((option) => option.value === value)) + .filter((option): option is SelectOption => !!option), + ) + + const selectedLabel = computed(() => selectedOptions.value[0]?.label ?? '') + + const visibleOptions = computed(() => { + const keyword = searchText.value.trim().toLowerCase() + if (!props.filterable || !keyword) { + return props.options + } + return props.options.filter( + (option) => option.label.toLowerCase().includes(keyword) || String(option.value).toLowerCase().includes(keyword), + ) + }) + + const selectedValueSet: ComputedRef> = computed(() => new Set(selectedValues.value)) + + const updateValue = (value: SelectModelValue) => { + emit('update:modelValue', value) + emit('change', value) + } + + const selectOption = (option: SelectOption) => { + if (option.disabled) { + return + } + + if (!props.multiple) { + updateValue(option.value) + return + } + + const next = selectedValues.value.includes(option.value) + ? selectedValues.value.filter((value) => value !== option.value) + : selectedValues.value.concat(option.value) + updateValue(next) + } + + const removeValue = (value: SelectRawValue) => { + if (!props.multiple) { + return + } + updateValue(selectedValues.value.filter((item) => item !== value)) + } + + const clearValue = () => { + updateValue(props.multiple ? [] : undefined) + } + + return { + clearValue, + removeValue, + selectOption, + selectedLabel, + selectedOptions, + selectedValueSet, + selectedValues, + visibleOptions, + } +} diff --git a/packages/ccui/ui/select/src/select-types.ts b/packages/ccui/ui/select/src/select-types.ts new file mode 100644 index 00000000..831fc642 --- /dev/null +++ b/packages/ccui/ui/select/src/select-types.ts @@ -0,0 +1,64 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type SelectRawValue = string | number +export type SelectModelValue = SelectRawValue | SelectRawValue[] | undefined +export type SelectSize = 'large' | 'default' | 'small' + +export interface SelectOption { + label: string + value: SelectRawValue + disabled?: boolean +} + +export const selectProps = { + modelValue: { + type: [String, Number, Array] as PropType, + default: undefined, + }, + options: { + type: Array as PropType, + default: () => [], + }, + placeholder: { + type: String, + default: 'Please select', + }, + disabled: { + type: Boolean, + default: false, + }, + clearable: { + type: Boolean, + default: false, + }, + multiple: { + type: Boolean, + default: false, + }, + filterable: { + type: Boolean, + default: false, + }, + loading: { + type: Boolean, + default: false, + }, + size: { + type: String as PropType, + default: 'default', + }, + noDataText: { + type: String, + default: 'No data', + }, + loadingText: { + type: String, + default: 'Loading...', + }, + maxTagCount: { + type: Number, + default: 3, + }, +} as const + +export type SelectProps = ExtractPropTypes diff --git a/packages/ccui/ui/select/src/select.scss b/packages/ccui/ui/select/src/select.scss new file mode 100644 index 00000000..eb21941f --- /dev/null +++ b/packages/ccui/ui/select/src/select.scss @@ -0,0 +1,208 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-select { + position: relative; + display: inline-block; + width: 220px; + color: $ccui-text; + font-size: $ccui-font-size; + vertical-align: middle; + + &--disabled { + cursor: not-allowed; + } + + &__selector { + display: flex; + min-height: 32px; + align-items: center; + gap: 4px; + box-sizing: border-box; + padding: 4px 28px 4px 11px; + border: 1px solid $ccui-color-border; + border-radius: $ccui-border-radius; + background: $ccui-color-bg-container; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; + } + + &__selector:hover, + &--open &__selector { + border-color: $ccui-primary; + } + + &--open &__selector { + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12); + } + + &--disabled &__selector { + border-color: $ccui-color-border; + background: $ccui-disabled-bg; + color: $ccui-color-text-disabled; + cursor: not-allowed; + box-shadow: none; + } + + &--large &__selector { + min-height: 40px; + } + + &--small &__selector { + min-height: 24px; + padding-top: 2px; + padding-bottom: 2px; + font-size: $ccui-font-size-sm; + } + + &__selection { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 4px; + overflow: hidden; + } + + &__placeholder { + color: $ccui-color-text-placeholder; + } + + &__single { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__search { + width: 100%; + min-width: 24px; + border: 0; + outline: 0; + background: transparent; + color: inherit; + font: inherit; + } + + &__tags { + display: flex; + min-width: 0; + flex: 1; + flex-wrap: wrap; + gap: 4px; + } + + &__tag { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 4px; + padding: 0 6px; + border-radius: 2px; + background: $ccui-color-fill-quaternary; + line-height: 22px; + } + + &__tag-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__tag-close { + color: $ccui-color-text-tertiary; + cursor: pointer; + } + + &__tag-close:hover { + color: $ccui-text; + } + + &__arrow, + &__clear { + position: absolute; + top: 50%; + right: 10px; + color: $ccui-color-text-tertiary; + line-height: 1; + transform: translateY(-50%); + } + + &__arrow { + transition: transform 0.2s; + } + + &--open &__arrow { + transform: translateY(-50%) rotate(180deg); + } + + &__clear { + cursor: pointer; + } + + &__dropdown { + position: absolute; + z-index: 1000; + top: calc(100% + 4px); + left: 0; + width: 100%; + box-sizing: border-box; + padding: 4px; + border: 1px solid $ccui-color-border-secondary; + border-radius: $ccui-border-radius; + background: $ccui-color-bg-elevated; + box-shadow: $ccui-box-shadow-secondary; + } + + &__list { + max-height: 240px; + padding: 0; + margin: 0; + overflow: auto; + list-style: none; + } + + &__option { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + } + + &__option:hover, + &__option--active { + background: $ccui-color-fill-tertiary; + } + + &__option--selected { + color: $ccui-primary; + font-weight: 600; + } + + &__option--disabled { + color: $ccui-color-text-disabled; + cursor: not-allowed; + } + + &__option--disabled:hover { + background: transparent; + } + + &__empty, + &__loading { + padding: 8px; + color: $ccui-color-text-tertiary; + text-align: center; + } + + &__check { + margin-left: 8px; + color: $ccui-primary; + } +} diff --git a/packages/ccui/ui/select/src/select.tsx b/packages/ccui/ui/select/src/select.tsx new file mode 100644 index 00000000..9e5b2c98 --- /dev/null +++ b/packages/ccui/ui/select/src/select.tsx @@ -0,0 +1,282 @@ +import type { VNode } from 'vue' +import type { SelectOption, SelectProps } from './select-types' +import { computed, defineComponent, h, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { useSelect } from './composables/use-select' +import { selectProps } from './select-types' +import './select.scss' + +export default defineComponent({ + name: 'CSelect', + props: selectProps, + emits: ['update:modelValue', 'change', 'search', 'visible-change', 'clear'], + setup(props: SelectProps, { emit }) { + const ns = useNamespace('select') + const rootRef = ref(null) + const inputRef = ref(null) + const open = shallowRef(false) + const activeIndex = shallowRef(0) + const searchText = shallowRef('') + + const { clearValue, removeValue, selectOption, selectedLabel, selectedOptions, selectedValueSet, visibleOptions } = + useSelect(props, searchText, emit) + + const hasValue = computed(() => selectedOptions.value.length > 0) + const visibleTags = computed(() => selectedOptions.value.slice(0, props.maxTagCount)) + const hiddenTagCount = computed(() => Math.max(selectedOptions.value.length - visibleTags.value.length, 0)) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.size)]: !!props.size, + [ns.m('open')]: open.value, + [ns.m('disabled')]: props.disabled, + [ns.m('multiple')]: props.multiple, + })) + + const setOpen = (value: boolean) => { + if (props.disabled || open.value === value) { + return + } + open.value = value + emit('visible-change', value) + if (value && props.filterable) { + setTimeout(() => inputRef.value?.focus()) + } + if (!value) { + searchText.value = '' + } + } + + const onDocumentClick = (event: MouseEvent) => { + if (!rootRef.value?.contains(event.target as Node)) { + setOpen(false) + } + } + + onMounted(() => { + document.addEventListener('mousedown', onDocumentClick) + }) + + onUnmounted(() => { + document.removeEventListener('mousedown', onDocumentClick) + }) + + const firstEnabledIndex = () => { + const index = visibleOptions.value.findIndex((option) => !option.disabled) + return index < 0 ? 0 : index + } + + watch( + () => visibleOptions.value.length, + () => { + activeIndex.value = firstEnabledIndex() + }, + ) + + const getNextEnabledIndex = (direction: 1 | -1) => { + const options = visibleOptions.value + if (options.length === 0) { + return 0 + } + + const start = activeIndex.value + for (let offset = 1; offset <= options.length; offset += 1) { + const index = (start + offset * direction + options.length) % options.length + if (!options[index].disabled) { + return index + } + } + + return start + } + + const onSearchInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value + searchText.value = value + emit('search', value) + setOpen(true) + } + + const onSelectOption = (option: SelectOption) => { + if (option.disabled) { + return + } + + selectOption(option) + if (!props.multiple) { + setOpen(false) + } + if (props.filterable) { + searchText.value = '' + } + } + + const onClear = (event: MouseEvent) => { + event.stopPropagation() + clearValue() + emit('clear') + setOpen(false) + } + + const onRemoveTag = (value: SelectOption['value'], event: MouseEvent) => { + event.stopPropagation() + removeValue(value) + } + + const onKeydown = (event: KeyboardEvent) => { + if (props.disabled) { + return + } + if (event.key === 'Escape') { + setOpen(false) + return + } + if (event.key === 'Enter') { + if (!open.value) { + setOpen(true) + return + } + const option = visibleOptions.value[activeIndex.value] + if (option) { + onSelectOption(option) + } + return + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return + } + event.preventDefault() + setOpen(true) + if (visibleOptions.value.length === 0) { + return + } + activeIndex.value = getNextEnabledIndex(event.key === 'ArrowDown' ? 1 : -1) + } + + const renderSearchInput = (placeholder: string) => + h('input', { + ref: inputRef, + class: ns.e('search'), + value: searchText.value, + placeholder, + disabled: props.disabled, + onInput: onSearchInput, + onKeydown, + }) + + const renderSingleSelection = () => { + if (props.filterable && open.value) { + return renderSearchInput(hasValue.value ? selectedLabel.value : props.placeholder) + } + + return h( + 'span', + { + class: hasValue.value ? ns.e('single') : ns.e('placeholder'), + }, + hasValue.value ? selectedLabel.value : props.placeholder, + ) + } + + const renderTags = (extraChildren: VNode[] = []) => + h('div', { class: ns.e('tags') }, [ + ...visibleTags.value.map((option) => + h('span', { class: ns.e('tag'), key: option.value }, [ + h('span', { class: ns.e('tag-label') }, option.label), + h( + 'span', + { + class: ns.e('tag-close'), + onClick: (event: MouseEvent) => onRemoveTag(option.value, event), + }, + 'x', + ), + ]), + ), + ...(hiddenTagCount.value > 0 + ? [h('span', { class: ns.e('tag'), key: '__more' }, `+${hiddenTagCount.value}`)] + : []), + ...extraChildren, + ]) + + const renderMultipleSelection = () => { + if (hasValue.value) { + return renderTags(props.filterable && open.value ? [renderSearchInput('')] : []) + } + if (props.filterable && open.value) { + return renderSearchInput(props.placeholder) + } + return h('span', { class: ns.e('placeholder') }, props.placeholder) + } + + const renderOption = (option: SelectOption, index: number) => { + const selected = selectedValueSet.value.has(option.value) + return h( + 'li', + { + key: option.value, + class: [ + ns.e('option'), + index === activeIndex.value && ns.em('option', 'active'), + selected && ns.em('option', 'selected'), + option.disabled && ns.em('option', 'disabled'), + ], + role: 'option', + 'aria-selected': selected, + onMouseenter: () => { + if (!option.disabled) { + activeIndex.value = index + } + }, + onClick: (event: MouseEvent) => { + event.stopPropagation() + onSelectOption(option) + }, + }, + [h('span', null, option.label), selected ? h('span', { class: ns.e('check') }, '*') : null], + ) + } + + const renderDropdown = () => { + if (!open.value) { + return null + } + if (props.loading) { + return h('div', { class: ns.e('dropdown') }, h('div', { class: ns.e('loading') }, props.loadingText)) + } + if (visibleOptions.value.length === 0) { + return h('div', { class: ns.e('dropdown') }, h('div', { class: ns.e('empty') }, props.noDataText)) + } + return h( + 'div', + { class: ns.e('dropdown') }, + h('ul', { class: ns.e('list'), role: 'listbox' }, visibleOptions.value.map(renderOption)), + ) + } + + return () => + h( + 'div', + { + ref: rootRef, + class: cls.value, + tabindex: props.disabled ? undefined : 0, + onClick: () => setOpen(true), + onKeydown, + }, + [ + h('div', { class: ns.e('selector') }, [ + h( + 'div', + { class: ns.e('selection') }, + props.multiple ? renderMultipleSelection() : renderSingleSelection(), + ), + props.clearable && hasValue.value && !props.disabled + ? h('span', { class: ns.e('clear'), onClick: onClear }, 'x') + : h('span', { class: ns.e('arrow') }, 'v'), + ]), + renderDropdown(), + ], + ) + }, +}) diff --git a/packages/ccui/ui/select/test/select.test.ts b/packages/ccui/ui/select/test/select.test.ts new file mode 100644 index 00000000..cbe5e8ba --- /dev/null +++ b/packages/ccui/ui/select/test/select.test.ts @@ -0,0 +1,201 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { nextTick } from 'vue' +import { Select } from '../index' +import { useNamespace } from '../../shared/hooks/use-namespace' + +const ns = useNamespace('select', true) +const options = [ + { label: 'Alpha', value: 'alpha' }, + { label: 'Beta', value: 'beta' }, + { label: 'Gamma', value: 'gamma', disabled: true }, +] +const manyOptions = options.concat({ label: 'Delta', value: 'delta' }) +const wrappers: VueWrapper[] = [] + +function mountSelect(props = {}) { + const wrapper = mount(Select, { props }) + wrappers.push(wrapper) + return wrapper +} + +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()) +}) + +describe('select', () => { + it('renders placeholder and opens options', async () => { + const wrapper = mountSelect({ options, placeholder: 'Pick one' }) + + expect(wrapper.find(ns.e('placeholder')).text()).toBe('Pick one') + await wrapper.trigger('click') + expect(wrapper.findAll(ns.e('option')).length).toBe(3) + }) + + it('emits selected value in single mode and closes the dropdown', async () => { + const wrapper = mountSelect({ options }) + + await wrapper.trigger('click') + await wrapper.findAll(ns.e('option'))[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['beta']) + expect(wrapper.emitted('change')?.[0]).toEqual(['beta']) + expect(wrapper.find(ns.e('dropdown')).exists()).toBe(false) + }) + + it('renders selected label from modelValue', () => { + const wrapper = mountSelect({ options, modelValue: 'alpha' }) + + expect(wrapper.find(ns.e('single')).text()).toBe('Alpha') + }) + + it('does not emit or close when a disabled option is clicked', async () => { + const wrapper = mountSelect({ options }) + + await wrapper.trigger('click') + await wrapper.findAll(ns.e('option'))[2].trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find(ns.e('dropdown')).exists()).toBe(true) + }) + + it('does not open when disabled', async () => { + const wrapper = mountSelect({ options, disabled: true }) + + await wrapper.trigger('click') + await wrapper.trigger('keydown', { key: 'Enter' }) + + expect(wrapper.find(ns.e('dropdown')).exists()).toBe(false) + expect(wrapper.emitted('visible-change')).toBeUndefined() + }) + + it('clears a single value and emits clear', async () => { + const wrapper = mountSelect({ options, modelValue: 'alpha', clearable: true }) + + await wrapper.find(ns.e('clear')).trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([undefined]) + expect(wrapper.emitted('change')?.[0]).toEqual([undefined]) + expect(wrapper.emitted('clear')?.length).toBe(1) + expect(wrapper.find(ns.e('dropdown')).exists()).toBe(false) + }) + + it('supports multiple selection, deselection, and tag removal', async () => { + const wrapper = mountSelect({ options, multiple: true }) + + await wrapper.trigger('click') + await wrapper.findAll(ns.e('option'))[0].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['alpha']]) + + await wrapper.setProps({ modelValue: ['alpha'] }) + await wrapper.findAll(ns.e('option'))[0].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[1]).toEqual([[]]) + + await wrapper.setProps({ modelValue: ['alpha'] }) + expect(wrapper.find(ns.e('tag')).text()).toContain('Alpha') + await wrapper.find(ns.e('tag-close')).trigger('click') + expect(wrapper.emitted('update:modelValue')?.[2]).toEqual([[]]) + }) + + it('clears multiple values as an empty array', async () => { + const wrapper = mountSelect({ options, multiple: true, modelValue: ['alpha', 'beta'], clearable: true }) + + await wrapper.find(ns.e('clear')).trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]]) + expect(wrapper.emitted('change')?.[0]).toEqual([[]]) + expect(wrapper.emitted('clear')?.length).toBe(1) + }) + + it('limits rendered tags with maxTagCount', () => { + const wrapper = mountSelect({ + options: manyOptions, + multiple: true, + modelValue: ['alpha', 'beta', 'delta'], + maxTagCount: 1, + }) + + const tags = wrapper.findAll(ns.e('tag')) + expect(tags.length).toBe(2) + expect(tags[0].text()).toContain('Alpha') + expect(tags[1].text()).toBe('+2') + }) + + it('filters options by label or value and emits search text', async () => { + const onSearch = vi.fn() + const wrapper = mountSelect({ options: manyOptions, filterable: true, onSearch }) + + await wrapper.trigger('click') + await wrapper.find('input').setValue('bet') + + expect(onSearch).toHaveBeenCalledWith('bet') + expect(wrapper.findAll(ns.e('option')).length).toBe(1) + expect(wrapper.find(ns.e('option')).text()).toContain('Beta') + + await wrapper.find('input').setValue('delta') + expect(wrapper.find(ns.e('option')).text()).toContain('Delta') + }) + + it('keeps search input available for multiple filterable select with selected tags', async () => { + const wrapper = mountSelect({ options, multiple: true, filterable: true, modelValue: ['alpha'] }) + + await wrapper.trigger('click') + expect(wrapper.find(ns.e('tag')).text()).toContain('Alpha') + expect(wrapper.find('input').exists()).toBe(true) + + await wrapper.find('input').setValue('bet') + expect(wrapper.findAll(ns.e('option')).length).toBe(1) + expect(wrapper.find(ns.e('option')).text()).toContain('Beta') + }) + + it('resets search text after single selection', async () => { + const wrapper = mountSelect({ options, filterable: true }) + + await wrapper.trigger('click') + await wrapper.find('input').setValue('bet') + await wrapper.find(ns.e('option')).trigger('click') + await wrapper.trigger('click') + + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') + expect(wrapper.findAll(ns.e('option')).length).toBe(3) + }) + + it('renders loading and empty states with custom text', async () => { + const wrapper = mountSelect({ options: [], loading: true, loadingText: 'Fetching', noDataText: 'Nothing here' }) + + await wrapper.trigger('click') + expect(wrapper.find(ns.e('loading')).text()).toBe('Fetching') + + await wrapper.setProps({ loading: false }) + expect(wrapper.find(ns.e('empty')).text()).toBe('Nothing here') + }) + + it('emits visible-change on open, escape close, and outside close', async () => { + const wrapper = mountSelect({ options }) + + await wrapper.trigger('click') + expect(wrapper.emitted('visible-change')?.[0]).toEqual([true]) + + await wrapper.trigger('keydown', { key: 'Escape' }) + expect(wrapper.emitted('visible-change')?.[1]).toEqual([false]) + + await wrapper.trigger('click') + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + + expect(wrapper.emitted('visible-change')?.[2]).toEqual([true]) + expect(wrapper.emitted('visible-change')?.[3]).toEqual([false]) + }) + + it('selects active enabled option from keyboard and skips disabled options', async () => { + const wrapper = mountSelect({ options }) + + await wrapper.trigger('keydown', { key: 'Enter' }) + await wrapper.trigger('keydown', { key: 'ArrowUp' }) + expect(wrapper.findAll(ns.e('option'))[1].classes()).toContain(ns.em('option', 'active').slice(1)) + + await wrapper.trigger('keydown', { key: 'Enter' }) + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['beta']) + }) +}) diff --git a/packages/docs/components/icon/index.md b/packages/docs/components/icon/index.md new file mode 100644 index 00000000..4ae421b9 --- /dev/null +++ b/packages/docs/components/icon/index.md @@ -0,0 +1,63 @@ +# Icon 图标 + +统一图标尺寸、颜色和旋转表现的基础包装器。 + +## 基本用法 + +:::demo + +```vue + + + +``` + +::: + +## 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| --------- | --------------- | ------ | -------------------------- | +| name | string | -- | 图标名称,优先从注册表解析 | +| component | Component | -- | 直接传入图标组件 | +| size | string / number | -- | 图标尺寸 | +| color | string | -- | 图标颜色 | +| rotate | number | 0 | 旋转角度 | +| spin | boolean | false | 是否旋转动画 | +| title | string | -- | 可访问标题 | +| ariaLabel | string | -- | 可访问标签 | + +## 注册 API + +| 方法 | 说明 | +| ------------------------------- | ---------------- | +| `registerIcon(name, component)` | 注册一个命名图标 | +| `resolveIcon(name)` | 读取已注册图标 | +| `unregisterIcon(name)` | 移除已注册图标 | +| `clearIconRegistry()` | 清空注册表 | diff --git a/packages/docs/components/select/index.md b/packages/docs/components/select/index.md new file mode 100644 index 00000000..86e0a832 --- /dev/null +++ b/packages/docs/components/select/index.md @@ -0,0 +1,108 @@ +# Select 选择器 + +从一组选项中选择一个或多个值。 + +## 基本用法 + +:::demo + +```vue + + + +``` + +::: + +## 多选 + +:::demo + +```vue + + + +``` + +::: + +## 可搜索 + +:::demo + +```vue + + + +``` + +::: + +## 参数 + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ----------------------- | -------- | ------------------------ | +| modelValue | string / number / array | -- | 绑定值 | +| options | SelectOption[] | [] | 选项列表 | +| placeholder | string | 请选择 | 占位文本 | +| disabled | boolean | false | 是否禁用 | +| clearable | boolean | false | 是否可清空 | +| multiple | boolean | false | 是否多选 | +| filterable | boolean | false | 是否可搜索 | +| loading | boolean | false | 是否加载中 | +| size | large / default / small | default | 尺寸 | +| noDataText | string | 暂无数据 | 空状态文本 | +| loadingText | string | 加载中 | 加载文本 | +| maxTagCount | number | 3 | 多选最多直接展示的标签数 | + +## 事件 + +| 事件 | 说明 | +| -------------- | ---------------- | +| change | 选中值变化 | +| search | 搜索文本变化 | +| visible-change | 下拉显示状态变化 | +| clear | 清空选择 | + +## 类型 + +```ts +interface SelectOption { + label: string + value: string | number + disabled?: boolean +} +``` From f9ec3b17c7b3def4b998db4b722b53b63b6caa36 Mon Sep 17 00:00:00 2001 From: zcj Date: Wed, 6 May 2026 10:37:33 +0800 Subject: [PATCH 013/277] feat: add form component --- COMPONENTS_DIFF.md | 62 ++++++--- packages/ccui/ui/form/index.ts | 23 ++++ packages/ccui/ui/form/src/form-item.tsx | 144 +++++++++++++++++++ packages/ccui/ui/form/src/form-types.ts | 106 ++++++++++++++ packages/ccui/ui/form/src/form.scss | 74 ++++++++++ packages/ccui/ui/form/src/form.tsx | 123 +++++++++++++++++ packages/ccui/ui/form/src/utils.ts | 120 ++++++++++++++++ packages/ccui/ui/form/test/form.test.ts | 175 ++++++++++++++++++++++++ packages/docs/components/form/index.md | 119 ++++++++++++++++ 9 files changed, 926 insertions(+), 20 deletions(-) create mode 100644 packages/ccui/ui/form/index.ts create mode 100644 packages/ccui/ui/form/src/form-item.tsx create mode 100644 packages/ccui/ui/form/src/form-types.ts create mode 100644 packages/ccui/ui/form/src/form.scss create mode 100644 packages/ccui/ui/form/src/form.tsx create mode 100644 packages/ccui/ui/form/src/utils.ts create mode 100644 packages/ccui/ui/form/test/form.test.ts create mode 100644 packages/docs/components/form/index.md diff --git a/COMPONENTS_DIFF.md b/COMPONENTS_DIFF.md index c230a315..f815eded 100644 --- a/COMPONENTS_DIFF.md +++ b/COMPONENTS_DIFF.md @@ -1,10 +1,10 @@ # vue3-ccui 与 Ant Design 组件对比清单 > 数据来源:Ant Design 官方组件总览(基于 v6.3.7 口径,共 71 个官方组件)。 -> 当前项目组件:58 个目录级组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 -> 更新时间:2026-05-05 13:10,根据当前 `packages/ccui/ui` 目录、Icon / Select 新增实现和验证结果更新。 +> 当前项目组件:59 个目录级组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 +> 更新时间:2026-05-06,根据当前 `packages/ccui/ui` 目录、Form 基础版新增实现和验证结果更新。 -## 一、已覆盖组件(58 项) +## 一、已覆盖组件(59 项) | ccui 组件 | Ant Design 对应 | 分类 | 状态 | | --------------------- | ----------------------- | --------------- | -------- | @@ -28,6 +28,7 @@ | Empty | Empty 空状态 | 数据展示 | 已完成 | | Flex | Flex 弹性布局 | 布局 | 已完成 | | FloatButton / BackTop | FloatButton 悬浮按钮 | 通用 | 已完成 | +| Form | Form 表单 | 数据录入 | 基础完成 | | Grid | Grid 栅格 | 布局 | 已完成 | | Icon | Icon 图标 | 通用 | 基础完成 | | Image | Image 图片 | 数据展示 | 已完成 | @@ -69,6 +70,7 @@ > 备注:`Status` 功能上接近 Ant Design 的 `Tag`,已有独立 `Tag` 后,建议后续把 `Status` 视为别名兼容或逐步废弃。 > `Select` 当前为基础可用版本,已覆盖单选、多选、搜索、清空、禁用、loading、空状态、tag 限制和基础键盘交互;尚未达到 Ant Design Select 的完整能力。 +> `Form` 当前为基础可用版本,已覆盖字段注册、规则校验、字段级校验、重置、清理校验和 submit 校验流程;尚未达到 Ant Design Form 的完整能力。 ## 二、缺失组件清单 @@ -80,21 +82,20 @@ | QRCode 二维码 | 数据展示 | 需要二维码生成库、纠错级别、图标嵌入 | P2 | | ColorPicker 颜色选择器 | 数据录入 | 色板、HSV/RGB/HEX 转换、透明度、浮层交互 | P2 | -### 复杂组件(11 项) - -| 组件 | 分类 | 复杂点 | 建议优先级 | -| --------------------- | -------- | ---------------------------------------- | ---------- | -| Form 表单 | 数据录入 | 字段管理、校验、联动、与所有录入组件耦合 | P0 | -| Table 表格 | 数据展示 | 排序、筛选、固定列、展开行、虚拟滚动 | P0 | -| DatePicker 日期选择框 | 数据录入 | 日期面板、范围、时间联动、国际化 | P1 | -| TimePicker 时间选择框 | 数据录入 | 滚轮选择、范围、禁用项 | P1 | -| Cascader 级联选择 | 数据录入 | 多级联动、异步加载、搜索 | P1 | -| TreeSelect 树选择 | 数据录入 | Select + Tree 组合、搜索、多选 | P1 | -| Transfer 穿梭框 | 数据录入 | 双列管理、搜索、分页、批量选择 | P2 | -| Upload 上传 | 数据录入 | 拖拽、切片、进度、预览、错误处理 | P2 | -| AutoComplete 自动完成 | 数据录入 | 与 Input 紧耦合、候选项、键盘交互 | P2 | -| Mentions 提及 | 数据录入 | contentEditable、触发解析、光标定位 | P3 | -| Tour 漫游引导 | 数据展示 | 多步定位、蒙层裁切、滚动跟随 | P3 | +### 复杂组件(10 项) + +| 组件 | 分类 | 复杂点 | 建议优先级 | +| --------------------- | -------- | ------------------------------------ | ---------- | +| Table 表格 | 数据展示 | 排序、筛选、固定列、展开行、虚拟滚动 | P0 | +| DatePicker 日期选择框 | 数据录入 | 日期面板、范围、时间联动、国际化 | P1 | +| TimePicker 时间选择框 | 数据录入 | 滚轮选择、范围、禁用项 | P1 | +| Cascader 级联选择 | 数据录入 | 多级联动、异步加载、搜索 | P1 | +| TreeSelect 树选择 | 数据录入 | Select + Tree 组合、搜索、多选 | P1 | +| Transfer 穿梭框 | 数据录入 | 双列管理、搜索、分页、批量选择 | P2 | +| Upload 上传 | 数据录入 | 拖拽、切片、进度、预览、错误处理 | P2 | +| AutoComplete 自动完成 | 数据录入 | 与 Input 紧耦合、候选项、键盘交互 | P2 | +| Mentions 提及 | 数据录入 | contentEditable、触发解析、光标定位 | P3 | +| Tour 漫游引导 | 数据展示 | 多步定位、蒙层裁切、滚动跟随 | P3 | ## 三、本轮交付记录 @@ -161,12 +162,33 @@ Select 剩余非完整对齐项: - 尚未支持 option group、自定义 option/tag render、remote search 约定、popup container / placement、fieldNames、虚拟列表、tags / allowCreate 模式、完整 ARIA 和 Form 校验状态集成。 +### Batch 5:Form 基础版 + +已完成 1 项:Form。 + +关键能力: + +- Form:支持 `model` / `rules`、字段注册与卸载、`validate` / `validateField` / `resetFields` / `clearValidate` 暴露方法、submit 自动校验、字段校验事件和失败事件。 +- FormItem:支持 `label`、`prop`、`required`、字段级 `rules`、`help`、`validateStatus`、label 宽度/位置、必填标记、错误/成功/校验中状态样式。 +- 校验规则:支持 required、type、min / max / len、pattern、同步/异步自定义 validator,并支持 blur / change / submit 触发规则过滤。 +- 已补充 7 个 Form 定向测试,覆盖布局渲染、必填/正则/长度校验、异步 validator、字段级校验、清理校验、重置字段、触发器过滤和 submit。 + +验证结果: + +- `vp check` 通过。 +- `vp test packages/ccui/ui/form/test/form.test.ts --environment jsdom` 通过,7 个用例通过。 +- `vp run --filter docs docs:build` 通过;仍有 Grid / Tag 既有 Sass deprecation warning。 + +Form 剩余非完整对齐项: + +- 尚未支持动态 Form.List、依赖联动、scrollToField / scrollToFirstError、复杂 name path 数组语法、状态图标、完整 ARIA、与所有录入组件的校验状态深度联动,以及 Ant Design Form 的完整 validateMessages / preserve 等高级配置。 + ## 四、后续任务规划 ### P0:补齐核心复杂组件 -1. Form:先定义字段上下文、校验协议、FormItem 数据流,再接入 Input / Select / Checkbox / Radio / Switch 等录入组件。 -2. Table:先交付基础列渲染、排序、筛选、分页联动,再拆固定列、展开行、虚拟滚动。 +1. Table:先交付基础列渲染、排序、筛选、分页联动,再拆固定列、展开行、虚拟滚动。 +2. Form:在基础版上继续补动态列表、依赖联动、滚动到错误字段、复杂 name path、校验状态图标和录入组件深度联动。 3. Select:在基础版上继续补 option group、自定义渲染、远程搜索协议、虚拟列表、popup 定位和 Form 校验状态集成。 ### P1:补齐高频录入和基础设施 diff --git a/packages/ccui/ui/form/index.ts b/packages/ccui/ui/form/index.ts new file mode 100644 index 00000000..6dda254e --- /dev/null +++ b/packages/ccui/ui/form/index.ts @@ -0,0 +1,23 @@ +import type { App } from 'vue' +import Form from './src/form' +import FormItem from './src/form-item' + +const FormInstall = Form as typeof Form & { install: (app: App) => void } + +FormInstall.install = function (app: App): void { + app.component(Form.name!, Form) + app.component(FormItem.name!, FormItem) +} + +export { Form, FormItem } +export type { FormRule, FormRules, FormValidateError, FormValidateTrigger } from './src/form-types' + +export default { + title: 'Form 表单', + category: '数据录入', + status: '35%', + install(app: App): void { + app.component(Form.name!, Form) + app.component(FormItem.name!, FormItem) + }, +} diff --git a/packages/ccui/ui/form/src/form-item.tsx b/packages/ccui/ui/form/src/form-item.tsx new file mode 100644 index 00000000..19522e46 --- /dev/null +++ b/packages/ccui/ui/form/src/form-item.tsx @@ -0,0 +1,144 @@ +import type { CSSProperties } from 'vue' +import type { FormItemContext, FormItemProps, FormRule, FormValidateTrigger } from './form-types' +import { computed, defineComponent, h, inject, onMounted, onUnmounted, ref } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { formInjectionKey, formItemProps } from './form-types' +import { getTriggeredRules, getValueByPath, normalizeRules, setValueByPath, validateRule } from './utils' +import './form.scss' + +export default defineComponent({ + name: 'CFormItem', + props: formItemProps, + setup(props: FormItemProps, { expose, slots }) { + const ns = useNamespace('form-item') + const form = inject(formInjectionKey, null) + const validateState = ref<'validating' | 'success' | 'error' | ''>('') + const validateMessage = ref('') + const initialValue = ref(props.prop && form ? getValueByPath(form.model.value, props.prop) : undefined) + + const mergedRules = computed(() => { + const rules = [...normalizeRules(form?.rules.value?.[props.prop]), ...normalizeRules(props.rules)] + + if (props.required && !rules.some((rule) => rule.required)) { + rules.unshift({ required: true, message: `${props.label || props.prop} is required` }) + } + + return rules + }) + + const isRequired = computed(() => props.required || mergedRules.value.some((rule) => rule.required)) + const currentStatus = computed(() => props.validateStatus || validateState.value) + const currentMessage = computed(() => validateMessage.value || props.help) + const labelStyle = computed(() => { + if (!form?.labelWidth.value || form.labelPosition.value === 'top') { + return {} + } + const width = typeof form.labelWidth.value === 'number' ? `${form.labelWidth.value}px` : form.labelWidth.value + return { width } + }) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(currentStatus.value)]: !!currentStatus.value, + [ns.m('required')]: isRequired.value, + [ns.m('top')]: form?.labelPosition.value === 'top', + })) + + const clearValidate = () => { + validateState.value = '' + validateMessage.value = '' + } + + const validate = async (trigger?: FormValidateTrigger) => { + if (!props.prop || !form) { + return true + } + + const rules = getTriggeredRules(mergedRules.value, trigger) + if (rules.length === 0) { + clearValidate() + return true + } + + validateState.value = 'validating' + const value = getValueByPath(form.model.value, props.prop) + + for (const rule of rules) { + const error = await validateRule(rule, value, form.model.value, props.prop) + if (error) { + validateState.value = 'error' + validateMessage.value = error.message + form.emitValidate(props.prop, false, error.message) + return false + } + } + + validateState.value = 'success' + validateMessage.value = '' + form.emitValidate(props.prop, true, '') + return true + } + + const resetField = () => { + if (props.prop && form) { + setValueByPath(form.model.value, props.prop, initialValue.value) + } + clearValidate() + } + + const fieldContext: FormItemContext = { + prop: props.prop, + validate, + resetField, + clearValidate, + } + + const onChangeCapture = () => { + void validate('change') + } + + const onFocusoutCapture = () => { + void validate('blur') + } + + onMounted(() => { + form?.addField(fieldContext) + }) + + onUnmounted(() => { + form?.removeField(fieldContext) + }) + + expose({ + validate, + resetField, + clearValidate, + }) + + return () => + h( + 'div', + { + class: cls.value, + onChangeCapture, + onFocusoutCapture, + }, + [ + props.label || slots.label + ? h( + 'label', + { + class: ns.e('label'), + style: labelStyle.value, + }, + slots.label?.() || props.label, + ) + : null, + h('div', { class: ns.e('content') }, [ + slots.default?.(), + currentMessage.value ? h('div', { class: ns.e('message') }, currentMessage.value) : null, + ]), + ], + ) + }, +}) diff --git a/packages/ccui/ui/form/src/form-types.ts b/packages/ccui/ui/form/src/form-types.ts new file mode 100644 index 00000000..7f3b4086 --- /dev/null +++ b/packages/ccui/ui/form/src/form-types.ts @@ -0,0 +1,106 @@ +import type { ExtractPropTypes, InjectionKey, PropType, Ref } from 'vue' + +export type FormLabelPosition = 'left' | 'right' | 'top' +export type FormValidateTrigger = 'change' | 'blur' | 'submit' +export type FormValidateStatus = '' | 'validating' | 'success' | 'error' +export type FormModel = Record + +export interface FormRule { + required?: boolean + message?: string + trigger?: FormValidateTrigger | FormValidateTrigger[] + type?: 'string' | 'number' | 'boolean' | 'array' | 'object' + min?: number + max?: number + len?: number + pattern?: RegExp + validator?: ( + rule: FormRule, + value: any, + model: FormModel, + ) => boolean | string | Error | Promise +} + +export type FormRules = Record + +export interface FormValidateError { + field: string + message: string +} + +export interface FormItemContext { + prop?: string + validate: (trigger?: FormValidateTrigger) => Promise + resetField: () => void + clearValidate: () => void +} + +export interface FormContext { + model: Ref + rules: Ref + disabled: Ref + labelWidth: Ref + labelPosition: Ref + addField: (field: FormItemContext) => void + removeField: (field: FormItemContext) => void + emitValidate: (field: string, valid: boolean, message: string) => void +} + +export const formInjectionKey: InjectionKey = Symbol('ccuiForm') + +export const formProps = { + model: { + type: Object as PropType, + default: () => ({}), + }, + rules: { + type: Object as PropType, + default: () => ({}), + }, + labelWidth: { + type: [String, Number] as PropType, + default: '', + }, + labelPosition: { + type: String as PropType, + default: 'right', + }, + disabled: { + type: Boolean, + default: false, + }, + validateOnRuleChange: { + type: Boolean, + default: true, + }, +} as const + +export const formItemProps = { + label: { + type: String, + default: '', + }, + prop: { + type: String, + default: '', + }, + required: { + type: Boolean, + default: false, + }, + rules: { + type: [Object, Array] as PropType, + default: undefined, + }, + help: { + type: String, + default: '', + }, + validateStatus: { + type: String as PropType, + default: '', + }, +} as const + +export type FormProps = ExtractPropTypes +export type FormItemProps = ExtractPropTypes diff --git a/packages/ccui/ui/form/src/form.scss b/packages/ccui/ui/form/src/form.scss new file mode 100644 index 00000000..218c602b --- /dev/null +++ b/packages/ccui/ui/form/src/form.scss @@ -0,0 +1,74 @@ +.ccui-form { + display: block; + color: var(--ccui-text-color, #1f2937); + font-size: 14px; +} + +.ccui-form--disabled { + cursor: not-allowed; +} + +.ccui-form-item { + display: flex; + align-items: flex-start; + margin-bottom: 18px; +} + +.ccui-form-item--top { + display: block; +} + +.ccui-form-item__label { + box-sizing: border-box; + flex: 0 0 auto; + min-height: 32px; + padding-right: 12px; + color: var(--ccui-text-color, #1f2937); + line-height: 32px; + text-align: right; +} + +.ccui-form--label-left .ccui-form-item__label { + text-align: left; +} + +.ccui-form--label-top .ccui-form-item__label, +.ccui-form-item--top .ccui-form-item__label { + display: block; + width: auto; + min-height: 0; + margin-bottom: 6px; + padding-right: 0; + line-height: 1.5; + text-align: left; +} + +.ccui-form-item--required .ccui-form-item__label::before { + margin-right: 4px; + color: var(--ccui-error-color, #ff4d4f); + content: '*'; +} + +.ccui-form-item__content { + flex: 1 1 auto; + min-width: 0; +} + +.ccui-form-item__message { + margin-top: 4px; + color: var(--ccui-text-color-secondary, #6b7280); + font-size: 12px; + line-height: 1.5; +} + +.ccui-form-item--error .ccui-form-item__message { + color: var(--ccui-error-color, #ff4d4f); +} + +.ccui-form-item--error :where(input, textarea, select) { + border-color: var(--ccui-error-color, #ff4d4f); +} + +.ccui-form-item--success .ccui-form-item__message { + color: var(--ccui-success-color, #52c41a); +} diff --git a/packages/ccui/ui/form/src/form.tsx b/packages/ccui/ui/form/src/form.tsx new file mode 100644 index 00000000..a9b55579 --- /dev/null +++ b/packages/ccui/ui/form/src/form.tsx @@ -0,0 +1,123 @@ +import type { FormItemContext, FormValidateError, FormValidateTrigger } from './form-types' +import type { FormProps } from './form-types' +import { computed, defineComponent, h, provide, ref, toRef, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { formInjectionKey, formProps } from './form-types' +import './form.scss' + +export default defineComponent({ + name: 'CForm', + props: formProps, + emits: ['submit', 'validate', 'validate-failed'], + setup(props: FormProps, { emit, expose, slots }) { + const ns = useNamespace('form') + const fields = ref([]) + + const addField = (field: FormItemContext) => { + if (field.prop && !fields.value.includes(field)) { + fields.value.push(field) + } + } + + const removeField = (field: FormItemContext) => { + fields.value = fields.value.filter((item) => item !== field) + } + + const emitValidate = (field: string, valid: boolean, message: string) => { + emit('validate', field, valid, message) + } + + provide(formInjectionKey, { + model: toRef(props, 'model'), + rules: toRef(props, 'rules'), + disabled: toRef(props, 'disabled'), + labelWidth: toRef(props, 'labelWidth'), + labelPosition: toRef(props, 'labelPosition'), + addField, + removeField, + emitValidate, + }) + + const cls = computed(() => ({ + [ns.b()]: true, + [ns.m(`label-${props.labelPosition}`)]: !!props.labelPosition, + [ns.m('disabled')]: props.disabled, + })) + + const validate = async (callback?: (valid: boolean, errors: FormValidateError[]) => void) => { + const errors: FormValidateError[] = [] + + for (const field of fields.value) { + const valid = await field.validate() + if (!valid && field.prop) { + errors.push({ field: field.prop, message: '' }) + } + } + + const valid = errors.length === 0 + callback?.(valid, errors) + if (!valid) { + emit('validate-failed', errors) + } + return valid + } + + const validateField = async (propsToValidate: string | string[], trigger?: FormValidateTrigger) => { + const propsList = Array.isArray(propsToValidate) ? propsToValidate : [propsToValidate] + const targets = fields.value.filter((field) => field.prop && propsList.includes(field.prop)) + const results = await Promise.all(targets.map((field) => field.validate(trigger))) + return results.every(Boolean) + } + + const resetFields = (propsToReset?: string | string[]) => { + const propsList = propsToReset ? (Array.isArray(propsToReset) ? propsToReset : [propsToReset]) : undefined + fields.value.forEach((field) => { + if (!propsList || (field.prop && propsList.includes(field.prop))) { + field.resetField() + } + }) + } + + const clearValidate = (propsToClear?: string | string[]) => { + const propsList = propsToClear ? (Array.isArray(propsToClear) ? propsToClear : [propsToClear]) : undefined + fields.value.forEach((field) => { + if (!propsList || (field.prop && propsList.includes(field.prop))) { + field.clearValidate() + } + }) + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + const valid = await validate() + emit('submit', valid, event) + } + + watch( + () => props.rules, + () => { + if (props.validateOnRuleChange) { + clearValidate() + } + }, + ) + + expose({ + validate, + validateField, + resetFields, + clearValidate, + }) + + return () => + h( + 'form', + { + class: cls.value, + novalidate: true, + onSubmit: handleSubmit, + }, + slots.default?.(), + ) + }, +}) diff --git a/packages/ccui/ui/form/src/utils.ts b/packages/ccui/ui/form/src/utils.ts new file mode 100644 index 00000000..69d16dd2 --- /dev/null +++ b/packages/ccui/ui/form/src/utils.ts @@ -0,0 +1,120 @@ +import type { FormModel, FormRule, FormValidateError, FormValidateTrigger } from './form-types' + +export function getValueByPath(model: FormModel, path: string): any { + if (!path) { + return undefined + } + return path.split('.').reduce((value, key) => value?.[key], model) +} + +export function setValueByPath(model: FormModel, path: string, value: any): void { + const keys = path.split('.') + const lastKey = keys.pop() + if (!lastKey) { + return + } + const target = keys.reduce((current, key) => { + if (!current[key] || typeof current[key] !== 'object') { + current[key] = {} + } + return current[key] + }, model) + target[lastKey] = value +} + +export function normalizeRules(rules?: FormRule | FormRule[]): FormRule[] { + if (!rules) { + return [] + } + return Array.isArray(rules) ? rules : [rules] +} + +export function getTriggeredRules(rules: FormRule[], trigger?: FormValidateTrigger): FormRule[] { + if (!trigger) { + return rules + } + + return rules.filter((rule) => { + if (!rule.trigger) { + return true + } + const triggers = Array.isArray(rule.trigger) ? rule.trigger : [rule.trigger] + return triggers.includes(trigger) + }) +} + +function isEmptyValue(value: any): boolean { + return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) +} + +function getTypeValid(rule: FormRule, value: any): boolean { + if (isEmptyValue(value) || !rule.type) { + return true + } + if (rule.type === 'array') { + return Array.isArray(value) + } + if (rule.type === 'object') { + return typeof value === 'object' && !Array.isArray(value) + } + return typeof value === rule.type +} + +function getSize(value: any): number | undefined { + if (typeof value === 'string' || Array.isArray(value)) { + return value.length + } + if (typeof value === 'number') { + return value + } + return undefined +} + +export async function validateRule( + rule: FormRule, + value: any, + model: FormModel, + field: string, +): Promise { + const fallbackMessage = rule.message || `${field} is invalid` + + if (rule.required && isEmptyValue(value)) { + return { field, message: rule.message || `${field} is required` } + } + + if (!getTypeValid(rule, value)) { + return { field, message: fallbackMessage } + } + + const size = getSize(value) + if (size !== undefined) { + if (rule.len !== undefined && size !== rule.len) { + return { field, message: fallbackMessage } + } + if (rule.min !== undefined && size < rule.min) { + return { field, message: fallbackMessage } + } + if (rule.max !== undefined && size > rule.max) { + return { field, message: fallbackMessage } + } + } + + if (rule.pattern && !rule.pattern.test(String(value))) { + return { field, message: fallbackMessage } + } + + if (rule.validator) { + const result = await rule.validator(rule, value, model) + if (result === false) { + return { field, message: fallbackMessage } + } + if (typeof result === 'string') { + return { field, message: result } + } + if (result instanceof Error) { + return { field, message: result.message } + } + } + + return null +} diff --git a/packages/ccui/ui/form/test/form.test.ts b/packages/ccui/ui/form/test/form.test.ts new file mode 100644 index 00000000..6ec71eaa --- /dev/null +++ b/packages/ccui/ui/form/test/form.test.ts @@ -0,0 +1,175 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vite-plus/test' +import { h, nextTick, reactive } from 'vue' +import { Form, FormItem } from '../index' +import { useNamespace } from '../../shared/hooks/use-namespace' + +const formNs = useNamespace('form', true) +const itemNs = useNamespace('form-item', true) + +function getFormVm(wrapper: VueWrapper) { + return wrapper.findComponent(Form).vm.$.exposed as { + validate: () => Promise + validateField: (prop: string | string[]) => Promise + resetFields: (prop?: string | string[]) => void + clearValidate: (prop?: string | string[]) => void + } +} + +describe('form', () => { + it('renders labels, required mark, and layout classes', () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + labelWidth: 96, + labelPosition: 'left', + }, + slots: { + default: () => h(FormItem, { label: 'Name', prop: 'name', required: true }, () => h('input')), + }, + }) + + expect(wrapper.classes()).toContain(formNs.m('label-left').slice(1)) + expect(wrapper.find(itemNs.e('label')).attributes('style')).toContain('width: 96px') + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('required').slice(1)) + }) + + it('validates required, pattern, and size rules', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '', code: 'ab' }, + rules: { + name: { required: true, message: 'Name is required' }, + code: { pattern: /^\d+$/, min: 3, message: 'Code is invalid' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { label: 'Name', prop: 'name' }, () => h('input')), + h(FormItem, { label: 'Code', prop: 'code' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual([ + 'Name is required', + 'Code is invalid', + ]) + expect(wrapper.emitted('validate')?.[0]).toEqual(['name', false, 'Name is required']) + }) + + it('supports async custom validators', async () => { + const validator = vi.fn(async (_rule, value: string) => value === 'ok' || 'Value must be ok') + const model = reactive({ status: 'bad' }) + const wrapper = mount(Form, { + props: { + model, + rules: { + status: { validator }, + }, + }, + slots: { + default: () => h(FormItem, { prop: 'status' }, () => h('input')), + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.find(itemNs.e('message')).text()).toBe('Value must be ok') + + model.status = 'ok' + await expect(getFormVm(wrapper).validate()).resolves.toBe(true) + expect(validator).toHaveBeenCalledTimes(2) + }) + + it('validates a single field and clears selected validation state', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '', age: '' }, + rules: { + name: { required: true, message: 'Missing name' }, + age: { required: true, message: 'Missing age' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'name' }, () => h('input')), + h(FormItem, { prop: 'age' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validateField('name')).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).length).toBe(1) + + getFormVm(wrapper).clearValidate('name') + await nextTick() + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + }) + + it('resets fields back to their initial values', async () => { + const model = reactive({ user: { name: 'Initial' } }) + const wrapper = mount(Form, { + props: { model }, + slots: { + default: () => h(FormItem, { prop: 'user.name' }, () => h('input')), + }, + }) + + await nextTick() + model.user.name = 'Changed' + getFormVm(wrapper).resetFields() + + expect(model.user.name).toBe('Initial') + }) + + it('filters validation rules by trigger from captured native events', async () => { + const model = reactive({ email: '' }) + const wrapper = mount(Form, { + props: { + model, + rules: { + email: [ + { required: true, trigger: 'blur', message: 'Email required' }, + { pattern: /@/, trigger: 'change', message: 'Email invalid' }, + ], + }, + }, + slots: { + default: () => h(FormItem, { prop: 'email' }, () => h('input', { value: model.email })), + }, + }) + + await wrapper.find('input').trigger('change') + expect(wrapper.find(itemNs.e('message')).text()).toBe('Email invalid') + + model.email = '' + getFormVm(wrapper).clearValidate() + await wrapper.find('input').trigger('focusout') + expect(wrapper.find(itemNs.e('message')).text()).toBe('Email required') + }) + + it('emits submit with validation result', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + rules: { + name: { required: true, message: 'Missing name' }, + }, + }, + slots: { + default: () => h(FormItem, { prop: 'name' }, () => h('button', { type: 'submit' }, 'Submit')), + }, + }) + + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(wrapper.emitted('submit')?.[0][0]).toBe(false) + expect(wrapper.emitted('validate-failed')?.length).toBe(1) + }) +}) diff --git a/packages/docs/components/form/index.md b/packages/docs/components/form/index.md new file mode 100644 index 00000000..7b6a18b2 --- /dev/null +++ b/packages/docs/components/form/index.md @@ -0,0 +1,119 @@ +# Form 表单 + +用于收集、校验和提交一组输入项。 + +## 基本用法 + +:::demo + +```vue + + + +``` + +::: + +## 表单校验 + +:::demo + +```vue + + + +``` + +::: + +## 参数 + +### Form + +| 参数 | 类型 | 默认值 | 说明 | +| -------------------- | ------------------ | ------ | ------------------ | +| model | object | {} | 表单数据对象 | +| rules | FormRules | {} | 表单校验规则 | +| labelWidth | string / number | -- | 标签宽度 | +| labelPosition | left / right / top | right | 标签位置 | +| disabled | boolean | false | 禁用态样式标记 | +| validateOnRuleChange | boolean | true | 规则变化时清理校验 | + +### FormItem + +| 参数 | 类型 | 默认值 | 说明 | +| -------------- | ---------------------------- | ------ | -------------- | +| label | string | -- | 标签文本 | +| prop | string | -- | model 字段路径 | +| required | boolean | false | 是否必填 | +| rules | FormRule / array | -- | 字段校验规则 | +| help | string | -- | 帮助文本 | +| validateStatus | success / error / validating | -- | 外部校验状态 | + +## 方法 + +| 方法 | 说明 | +| ------------- | ---------------- | +| validate | 校验全部字段 | +| validateField | 校验指定字段 | +| resetFields | 重置字段值和状态 | +| clearValidate | 清理校验状态 | + +## 事件 + +| 事件 | 说明 | +| --------------- | ------------------ | +| submit | 表单提交后触发 | +| validate | 字段校验完成后触发 | +| validate-failed | 表单校验失败后触发 | + +## 当前限制 + +当前为基础可用版本,已覆盖字段注册、规则校验、字段级方法、重置和提交。尚未完整对齐 Ant Design Form 的动态列表、依赖联动、滚动到错误字段、复杂 name path、状态图标和所有录入组件的校验状态联动。 From a08ed56e5caebdc2f68d7fe676906e11fad4a4ba Mon Sep 17 00:00:00 2001 From: zcj Date: Wed, 6 May 2026 11:49:09 +0800 Subject: [PATCH 014/277] feat: expand form component coverage --- COMPONENTS_DIFF.md | 19 +- packages/ccui/ui/form/index.ts | 13 +- packages/ccui/ui/form/src/form-item.tsx | 161 +++++-- packages/ccui/ui/form/src/form-types.ts | 106 ++++- packages/ccui/ui/form/src/form.scss | 41 ++ packages/ccui/ui/form/src/form.tsx | 98 +++-- packages/ccui/ui/form/src/utils.ts | 182 +++++++- packages/ccui/ui/form/test/form.test.ts | 539 ++++++++++++++++++++++++ packages/docs/components/form/index.md | 118 +++++- 9 files changed, 1154 insertions(+), 123 deletions(-) diff --git a/COMPONENTS_DIFF.md b/COMPONENTS_DIFF.md index f815eded..1181c660 100644 --- a/COMPONENTS_DIFF.md +++ b/COMPONENTS_DIFF.md @@ -1,7 +1,8 @@ # vue3-ccui 与 Ant Design 组件对比清单 > 数据来源:Ant Design 官方组件总览(基于 v6.3.7 口径,共 71 个官方组件)。 -> 当前项目组件:59 个目录级组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 +> 当前项目目录:`packages/ccui/ui` 下共 61 个一级目录,其中 59 个组件/工具入口;`shared` 与 `style-var` 为内部支撑目录,不计入组件覆盖数。 +> 当前项目组件:59 个组件/工具入口(含 `button-3d` 项目特色组件、`masonry` 布局扩展、`util` 工具入口)。 > 更新时间:2026-05-06,根据当前 `packages/ccui/ui` 目录、Form 基础版新增实现和验证结果更新。 ## 一、已覆盖组件(59 项) @@ -28,7 +29,7 @@ | Empty | Empty 空状态 | 数据展示 | 已完成 | | Flex | Flex 弹性布局 | 布局 | 已完成 | | FloatButton / BackTop | FloatButton 悬浮按钮 | 通用 | 已完成 | -| Form | Form 表单 | 数据录入 | 基础完成 | +| Form | Form 表单 | 数据录入 | 80% 完成 | | Grid | Grid 栅格 | 布局 | 已完成 | | Icon | Icon 图标 | 通用 | 基础完成 | | Image | Image 图片 | 数据展示 | 已完成 | @@ -70,7 +71,7 @@ > 备注:`Status` 功能上接近 Ant Design 的 `Tag`,已有独立 `Tag` 后,建议后续把 `Status` 视为别名兼容或逐步废弃。 > `Select` 当前为基础可用版本,已覆盖单选、多选、搜索、清空、禁用、loading、空状态、tag 限制和基础键盘交互;尚未达到 Ant Design Select 的完整能力。 -> `Form` 当前为基础可用版本,已覆盖字段注册、规则校验、字段级校验、重置、清理校验和 submit 校验流程;尚未达到 Ant Design Form 的完整能力。 +> `Form` 当前为 80% 覆盖版本,已覆盖字段注册、规则校验、字段级校验、初始值、依赖联动、滚动到错误字段、复杂 name path、重置、清理校验和 submit 校验流程;尚未包含 Form.List、Provider、preserve 等完整高级能力。 ## 二、缺失组件清单 @@ -162,16 +163,16 @@ Select 剩余非完整对齐项: - 尚未支持 option group、自定义 option/tag render、remote search 约定、popup container / placement、fieldNames、虚拟列表、tags / allowCreate 模式、完整 ARIA 和 Form 校验状态集成。 -### Batch 5:Form 基础版 +### Batch 5:Form 80% 覆盖版 已完成 1 项:Form。 关键能力: -- Form:支持 `model` / `rules`、字段注册与卸载、`validate` / `validateField` / `resetFields` / `clearValidate` 暴露方法、submit 自动校验、字段校验事件和失败事件。 -- FormItem:支持 `label`、`prop`、`required`、字段级 `rules`、`help`、`validateStatus`、label 宽度/位置、必填标记、错误/成功/校验中状态样式。 -- 校验规则:支持 required、type、min / max / len、pattern、同步/异步自定义 validator,并支持 blur / change / submit 触发规则过滤。 -- 已补充 7 个 Form 定向测试,覆盖布局渲染、必填/正则/长度校验、异步 validator、字段级校验、清理校验、重置字段、触发器过滤和 submit。 +- Form:支持 `model` / `rules` / `initialValues` / `validateMessages`、字段注册与卸载、`validate` / `validateField` / `resetFields` / `clearValidate` / `scrollToField` 暴露方法、submit 自动校验、字段校验事件、失败事件和 `scrollToFirstError`。 +- FormItem:支持 `label`、`name` / `prop`、`required`、字段级 `rules`、`help`、`extra`、`validateStatus`、`dependencies`、`htmlFor`、`colon`、`hidden`、`noStyle`、label 宽度/位置、必填/可选标记、错误/成功/校验中状态样式。 +- 校验规则:支持 required、type、email、url、enum、whitespace、min / max / len、pattern、同步/异步自定义 validator,并支持 blur / change / submit 触发规则过滤和消息模板。 +- 已补充 32 个 Form 定向测试,覆盖布局渲染、必填/正则/长度校验、异步 validator、字段级校验、清理校验、重置字段、触发器过滤、submit、数组 name path、初始值、消息模板、依赖联动、隐藏/noStyle 字段、类型校验和滚动到错误字段。 验证结果: @@ -181,7 +182,7 @@ Select 剩余非完整对齐项: Form 剩余非完整对齐项: -- 尚未支持动态 Form.List、依赖联动、scrollToField / scrollToFirstError、复杂 name path 数组语法、状态图标、完整 ARIA、与所有录入组件的校验状态深度联动,以及 Ant Design Form 的完整 validateMessages / preserve 等高级配置。 +- 尚未支持动态 `Form.List`、`Form.Provider`、`preserve`、`validateDebounce`、`normalize`、`getValueProps`、`shouldUpdate` / render props、状态图标、完整 ARIA、与所有录入组件的校验状态深度联动,以及更完整的滚动容器定位和国际化包级默认文案。 ## 四、后续任务规划 diff --git a/packages/ccui/ui/form/index.ts b/packages/ccui/ui/form/index.ts index 6dda254e..65b7330d 100644 --- a/packages/ccui/ui/form/index.ts +++ b/packages/ccui/ui/form/index.ts @@ -10,12 +10,21 @@ FormInstall.install = function (app: App): void { } export { Form, FormItem } -export type { FormRule, FormRules, FormValidateError, FormValidateTrigger } from './src/form-types' +export type { + FormLayout, + FormNamePath, + FormRequiredMark, + FormRule, + FormRules, + FormValidateError, + FormValidateMessages, + FormValidateTrigger, +} from './src/form-types' export default { title: 'Form 表单', category: '数据录入', - status: '35%', + status: '80%', install(app: App): void { app.component(Form.name!, Form) app.component(FormItem.name!, FormItem) diff --git a/packages/ccui/ui/form/src/form-item.tsx b/packages/ccui/ui/form/src/form-item.tsx index 19522e46..ff5280a9 100644 --- a/packages/ccui/ui/form/src/form-item.tsx +++ b/packages/ccui/ui/form/src/form-item.tsx @@ -1,9 +1,17 @@ import type { CSSProperties } from 'vue' import type { FormItemContext, FormItemProps, FormRule, FormValidateTrigger } from './form-types' -import { computed, defineComponent, h, inject, onMounted, onUnmounted, ref } from 'vue' +import { computed, defineComponent, h, inject, onMounted, onUnmounted, ref, watch } from 'vue' import { useNamespace } from '../../shared/hooks/use-namespace' import { formInjectionKey, formItemProps } from './form-types' -import { getTriggeredRules, getValueByPath, normalizeRules, setValueByPath, validateRule } from './utils' +import { + cloneValue, + getPathKey, + getTriggeredRules, + getValueByPath, + normalizeRules, + setValueByPath, + validateRule, +} from './utils' import './form.scss' export default defineComponent({ @@ -14,13 +22,28 @@ export default defineComponent({ const form = inject(formInjectionKey, null) const validateState = ref<'validating' | 'success' | 'error' | ''>('') const validateMessage = ref('') - const initialValue = ref(props.prop && form ? getValueByPath(form.model.value, props.prop) : undefined) + const itemRef = ref(null) + const fieldName = computed(() => props.name ?? props.prop) + const fieldKey = computed(() => getPathKey(fieldName.value)) + const labelText = computed(() => props.label || fieldKey.value) + const initialValue = ref() + + const getInitialValue = () => { + if (props.initialValue !== undefined) { + return props.initialValue + } + const formInitialValue = form ? getValueByPath(form.initialValues.value, fieldName.value) : undefined + if (formInitialValue !== undefined) { + return formInitialValue + } + return form ? getValueByPath(form.model.value, fieldName.value) : undefined + } const mergedRules = computed(() => { - const rules = [...normalizeRules(form?.rules.value?.[props.prop]), ...normalizeRules(props.rules)] + const rules = [...normalizeRules(form?.rules.value?.[fieldKey.value]), ...normalizeRules(props.rules)] if (props.required && !rules.some((rule) => rule.required)) { - rules.unshift({ required: true, message: `${props.label || props.prop} is required` }) + rules.unshift({ required: true }) } return rules @@ -29,8 +52,11 @@ export default defineComponent({ const isRequired = computed(() => props.required || mergedRules.value.some((rule) => rule.required)) const currentStatus = computed(() => props.validateStatus || validateState.value) const currentMessage = computed(() => validateMessage.value || props.help) + const shouldShowOptional = computed(() => !isRequired.value && form?.requiredMark.value === 'optional') + const shouldShowRequiredMark = computed(() => isRequired.value && form?.requiredMark.value !== false) + const mergedColon = computed(() => props.colon ?? form?.colon.value ?? true) const labelStyle = computed(() => { - if (!form?.labelWidth.value || form.labelPosition.value === 'top') { + if (!form?.labelWidth.value || form.labelPosition.value === 'top' || form.layout.value === 'vertical') { return {} } const width = typeof form.labelWidth.value === 'number' ? `${form.labelWidth.value}px` : form.labelWidth.value @@ -40,8 +66,11 @@ export default defineComponent({ const cls = computed(() => ({ [ns.b()]: true, [ns.m(currentStatus.value)]: !!currentStatus.value, - [ns.m('required')]: isRequired.value, - [ns.m('top')]: form?.labelPosition.value === 'top', + [ns.m('required')]: shouldShowRequiredMark.value, + [ns.m('optional')]: shouldShowOptional.value, + [ns.m('no-style')]: props.noStyle, + [ns.m('hidden')]: props.hidden, + [ns.m('top')]: form?.labelPosition.value === 'top' || form?.layout.value === 'vertical', })) const clearValidate = () => { @@ -50,7 +79,7 @@ export default defineComponent({ } const validate = async (trigger?: FormValidateTrigger) => { - if (!props.prop || !form) { + if (!fieldKey.value || !form) { return true } @@ -61,36 +90,47 @@ export default defineComponent({ } validateState.value = 'validating' - const value = getValueByPath(form.model.value, props.prop) + const value = getValueByPath(form.model.value, fieldName.value) for (const rule of rules) { - const error = await validateRule(rule, value, form.model.value, props.prop) + const error = await validateRule( + rule, + value, + form.model.value, + fieldKey.value, + labelText.value, + form.validateMessages.value, + ) if (error) { validateState.value = 'error' validateMessage.value = error.message - form.emitValidate(props.prop, false, error.message) + form.emitValidate(fieldKey.value, false, error.message) return false } } validateState.value = 'success' validateMessage.value = '' - form.emitValidate(props.prop, true, '') + form.emitValidate(fieldKey.value, true, '') return true } const resetField = () => { - if (props.prop && form) { - setValueByPath(form.model.value, props.prop, initialValue.value) + if (fieldKey.value && form) { + setValueByPath(form.model.value, fieldName.value!, cloneValue(initialValue.value)) } clearValidate() } const fieldContext: FormItemContext = { - prop: props.prop, + prop: fieldName.value, + field: fieldKey.value, + dependencies: props.dependencies, validate, resetField, clearValidate, + getValidateMessage: () => validateMessage.value, + getElement: () => itemRef.value, } const onChangeCapture = () => { @@ -102,6 +142,13 @@ export default defineComponent({ } onMounted(() => { + initialValue.value = cloneValue(getInitialValue()) + if (fieldName.value && form && getValueByPath(form.model.value, fieldName.value) === undefined) { + const value = getInitialValue() + if (value !== undefined) { + setValueByPath(form.model.value, fieldName.value, cloneValue(value)) + } + } form?.addField(fieldContext) }) @@ -115,30 +162,68 @@ export default defineComponent({ clearValidate, }) - return () => - h( - 'div', + watch( + () => form?.model.value, + () => { + if (props.dependencies.length > 0) { + void validate() + } + }, + { deep: true }, + ) + + const renderLabel = () => { + if (!props.label && !slots.label) { + return null + } + + const content = [ + slots.label?.() || props.label, + shouldShowOptional.value ? h('span', { class: ns.e('optional') }, ' (optional)') : null, + ] + + return h( + 'label', { - class: cls.value, - onChangeCapture, - onFocusoutCapture, + class: [ns.e('label'), mergedColon.value && ns.em('label', 'colon')], + style: labelStyle.value, + for: props.htmlFor || undefined, }, - [ - props.label || slots.label - ? h( - 'label', - { - class: ns.e('label'), - style: labelStyle.value, - }, - slots.label?.() || props.label, - ) - : null, - h('div', { class: ns.e('content') }, [ - slots.default?.(), - currentMessage.value ? h('div', { class: ns.e('message') }, currentMessage.value) : null, - ]), - ], + content, ) + } + + const renderContent = () => + h('div', { class: ns.e('content') }, [ + slots.default?.(), + currentMessage.value ? h('div', { class: ns.e('message'), role: 'alert' }, currentMessage.value) : null, + props.extra ? h('div', { class: ns.e('extra') }, props.extra) : null, + ]) + + return () => + props.noStyle + ? h( + 'div', + { + ref: itemRef, + class: cls.value, + 'data-field': fieldKey.value || undefined, + onChangeCapture, + onFocusoutCapture, + }, + slots.default?.(), + ) + : h( + 'div', + { + ref: itemRef, + class: cls.value, + 'data-field': fieldKey.value || undefined, + 'aria-invalid': currentStatus.value === 'error' ? 'true' : undefined, + onChangeCapture, + onFocusoutCapture, + }, + [renderLabel(), renderContent()], + ) }, }) diff --git a/packages/ccui/ui/form/src/form-types.ts b/packages/ccui/ui/form/src/form-types.ts index 7f3b4086..c450c1a8 100644 --- a/packages/ccui/ui/form/src/form-types.ts +++ b/packages/ccui/ui/form/src/form-types.ts @@ -1,19 +1,51 @@ import type { ExtractPropTypes, InjectionKey, PropType, Ref } from 'vue' +export type FormNamePath = string | number | Array export type FormLabelPosition = 'left' | 'right' | 'top' +export type FormLayout = 'horizontal' | 'vertical' | 'inline' export type FormValidateTrigger = 'change' | 'blur' | 'submit' export type FormValidateStatus = '' | 'validating' | 'success' | 'error' export type FormModel = Record +export type FormRequiredMark = boolean | 'optional' + +export interface FormValidateMessages { + default?: string + required?: string + enum?: string + whitespace?: string + pattern?: string + types?: Partial, string>> + string?: { + len?: string + min?: string + max?: string + range?: string + } + number?: { + len?: string + min?: string + max?: string + range?: string + } + array?: { + len?: string + min?: string + max?: string + range?: string + } +} export interface FormRule { required?: boolean message?: string trigger?: FormValidateTrigger | FormValidateTrigger[] - type?: 'string' | 'number' | 'boolean' | 'array' | 'object' + type?: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'email' | 'url' min?: number max?: number len?: number pattern?: RegExp + enum?: any[] + whitespace?: boolean validator?: ( rule: FormRule, value: any, @@ -29,21 +61,31 @@ export interface FormValidateError { } export interface FormItemContext { - prop?: string + prop?: FormNamePath + field: string + dependencies: FormNamePath[] validate: (trigger?: FormValidateTrigger) => Promise resetField: () => void clearValidate: () => void + getValidateMessage: () => string + getElement: () => HTMLElement | null } export interface FormContext { model: Ref rules: Ref + initialValues: Ref + validateMessages: Ref disabled: Ref labelWidth: Ref labelPosition: Ref + layout: Ref + colon: Ref + requiredMark: Ref addField: (field: FormItemContext) => void removeField: (field: FormItemContext) => void emitValidate: (field: string, valid: boolean, message: string) => void + validateField: (propsToValidate: FormNamePath | FormNamePath[], trigger?: FormValidateTrigger) => Promise } export const formInjectionKey: InjectionKey = Symbol('ccuiForm') @@ -57,6 +99,10 @@ export const formProps = { type: Object as PropType, default: () => ({}), }, + initialValues: { + type: Object as PropType, + default: () => ({}), + }, labelWidth: { type: [String, Number] as PropType, default: '', @@ -69,20 +115,48 @@ export const formProps = { type: Boolean, default: false, }, + layout: { + type: String as PropType, + default: 'horizontal', + }, + colon: { + type: Boolean, + default: true, + }, + requiredMark: { + type: [Boolean, String] as PropType, + default: true, + }, + validateMessages: { + type: Object as PropType, + default: () => ({}), + }, validateOnRuleChange: { type: Boolean, default: true, }, + scrollToFirstError: { + type: [Boolean, Object] as PropType, + default: false, + }, } as const export const formItemProps = { + name: { + type: [String, Number, Array] as PropType, + default: undefined, + }, label: { type: String, default: '', }, prop: { - type: String, - default: '', + type: [String, Number, Array] as PropType, + default: undefined, + }, + initialValue: { + type: null as unknown as PropType, + default: undefined, }, required: { type: Boolean, @@ -100,6 +174,30 @@ export const formItemProps = { type: String as PropType, default: '', }, + extra: { + type: String, + default: '', + }, + htmlFor: { + type: String, + default: '', + }, + colon: { + type: Boolean, + default: undefined, + }, + hidden: { + type: Boolean, + default: false, + }, + noStyle: { + type: Boolean, + default: false, + }, + dependencies: { + type: Array as PropType, + default: () => [], + }, } as const export type FormProps = ExtractPropTypes diff --git a/packages/ccui/ui/form/src/form.scss b/packages/ccui/ui/form/src/form.scss index 218c602b..19b97694 100644 --- a/packages/ccui/ui/form/src/form.scss +++ b/packages/ccui/ui/form/src/form.scss @@ -4,6 +4,21 @@ font-size: 14px; } +.ccui-form--vertical .ccui-form-item { + display: block; +} + +.ccui-form--inline { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + align-items: flex-start; +} + +.ccui-form--inline .ccui-form-item { + margin-bottom: 0; +} + .ccui-form--disabled { cursor: not-allowed; } @@ -18,6 +33,15 @@ display: block; } +.ccui-form-item--hidden { + display: none; +} + +.ccui-form-item--no-style { + display: contents; + margin-bottom: 0; +} + .ccui-form-item__label { box-sizing: border-box; flex: 0 0 auto; @@ -28,6 +52,11 @@ text-align: right; } +.ccui-form-item__label--colon::after { + margin-inline-start: 2px; + content: ':'; +} + .ccui-form--label-left .ccui-form-item__label { text-align: left; } @@ -49,6 +78,11 @@ content: '*'; } +.ccui-form-item__optional { + color: var(--ccui-text-color-secondary, #6b7280); + font-weight: 400; +} + .ccui-form-item__content { flex: 1 1 auto; min-width: 0; @@ -61,6 +95,13 @@ line-height: 1.5; } +.ccui-form-item__extra { + margin-top: 4px; + color: var(--ccui-text-color-secondary, #6b7280); + font-size: 12px; + line-height: 1.5; +} + .ccui-form-item--error .ccui-form-item__message { color: var(--ccui-error-color, #ff4d4f); } diff --git a/packages/ccui/ui/form/src/form.tsx b/packages/ccui/ui/form/src/form.tsx index a9b55579..ccdd1010 100644 --- a/packages/ccui/ui/form/src/form.tsx +++ b/packages/ccui/ui/form/src/form.tsx @@ -1,8 +1,9 @@ -import type { FormItemContext, FormValidateError, FormValidateTrigger } from './form-types' +import type { FormItemContext, FormNamePath, FormValidateError, FormValidateTrigger } from './form-types' import type { FormProps } from './form-types' import { computed, defineComponent, h, provide, ref, toRef, watch } from 'vue' import { useNamespace } from '../../shared/hooks/use-namespace' import { formInjectionKey, formProps } from './form-types' +import { getPathKey } from './utils' import './form.scss' export default defineComponent({ @@ -13,8 +14,26 @@ export default defineComponent({ const ns = useNamespace('form') const fields = ref([]) + const getFieldList = (propsToHandle?: FormNamePath | FormNamePath[]) => { + if (propsToHandle === undefined) { + return fields.value + } + if (!Array.isArray(propsToHandle)) { + const key = getPathKey(propsToHandle) + return fields.value.filter((field) => field.field === key) + } + + const directKey = getPathKey(propsToHandle as FormNamePath) + if (fields.value.some((field) => field.field === directKey)) { + return fields.value.filter((field) => field.field === directKey) + } + + const keys = new Set(propsToHandle.map((item) => getPathKey(item as FormNamePath))) + return fields.value.filter((field) => keys.has(field.field)) + } + const addField = (field: FormItemContext) => { - if (field.prop && !fields.value.includes(field)) { + if (field.field && !fields.value.includes(field)) { fields.value.push(field) } } @@ -27,20 +46,10 @@ export default defineComponent({ emit('validate', field, valid, message) } - provide(formInjectionKey, { - model: toRef(props, 'model'), - rules: toRef(props, 'rules'), - disabled: toRef(props, 'disabled'), - labelWidth: toRef(props, 'labelWidth'), - labelPosition: toRef(props, 'labelPosition'), - addField, - removeField, - emitValidate, - }) - const cls = computed(() => ({ [ns.b()]: true, [ns.m(`label-${props.labelPosition}`)]: !!props.labelPosition, + [ns.m(props.layout)]: !!props.layout, [ns.m('disabled')]: props.disabled, })) @@ -50,7 +59,7 @@ export default defineComponent({ for (const field of fields.value) { const valid = await field.validate() if (!valid && field.prop) { - errors.push({ field: field.prop, message: '' }) + errors.push({ field: field.field, message: field.getValidateMessage() }) } } @@ -58,33 +67,30 @@ export default defineComponent({ callback?.(valid, errors) if (!valid) { emit('validate-failed', errors) + if (props.scrollToFirstError) { + scrollToField(errors[0].field, props.scrollToFirstError === true ? undefined : props.scrollToFirstError) + } } return valid } - const validateField = async (propsToValidate: string | string[], trigger?: FormValidateTrigger) => { - const propsList = Array.isArray(propsToValidate) ? propsToValidate : [propsToValidate] - const targets = fields.value.filter((field) => field.prop && propsList.includes(field.prop)) + const validateField = async (propsToValidate: FormNamePath | FormNamePath[], trigger?: FormValidateTrigger) => { + const targets = getFieldList(propsToValidate) const results = await Promise.all(targets.map((field) => field.validate(trigger))) return results.every(Boolean) } - const resetFields = (propsToReset?: string | string[]) => { - const propsList = propsToReset ? (Array.isArray(propsToReset) ? propsToReset : [propsToReset]) : undefined - fields.value.forEach((field) => { - if (!propsList || (field.prop && propsList.includes(field.prop))) { - field.resetField() - } - }) + const resetFields = (propsToReset?: FormNamePath | FormNamePath[]) => { + getFieldList(propsToReset).forEach((field) => field.resetField()) } - const clearValidate = (propsToClear?: string | string[]) => { - const propsList = propsToClear ? (Array.isArray(propsToClear) ? propsToClear : [propsToClear]) : undefined - fields.value.forEach((field) => { - if (!propsList || (field.prop && propsList.includes(field.prop))) { - field.clearValidate() - } - }) + const clearValidate = (propsToClear?: FormNamePath | FormNamePath[]) => { + getFieldList(propsToClear).forEach((field) => field.clearValidate()) + } + + const scrollToField = (name: FormNamePath, options?: ScrollIntoViewOptions) => { + const field = getFieldList(name)[0] + field?.getElement()?.scrollIntoView(options ?? { block: 'nearest' }) } const handleSubmit = async (event: Event) => { @@ -102,11 +108,41 @@ export default defineComponent({ }, ) + watch( + () => props.model, + () => { + fields.value.forEach((field) => { + if (field.dependencies.length > 0) { + void field.validate() + } + }) + }, + { deep: true }, + ) + expose({ validate, validateField, resetFields, clearValidate, + scrollToField, + }) + + provide(formInjectionKey, { + model: toRef(props, 'model'), + rules: toRef(props, 'rules'), + initialValues: toRef(props, 'initialValues'), + validateMessages: toRef(props, 'validateMessages'), + disabled: toRef(props, 'disabled'), + labelWidth: toRef(props, 'labelWidth'), + labelPosition: toRef(props, 'labelPosition'), + layout: toRef(props, 'layout'), + colon: toRef(props, 'colon'), + requiredMark: toRef(props, 'requiredMark'), + addField, + removeField, + emitValidate, + validateField, }) return () => diff --git a/packages/ccui/ui/form/src/utils.ts b/packages/ccui/ui/form/src/utils.ts index 69d16dd2..03772dc0 100644 --- a/packages/ccui/ui/form/src/utils.ts +++ b/packages/ccui/ui/form/src/utils.ts @@ -1,16 +1,44 @@ -import type { FormModel, FormRule, FormValidateError, FormValidateTrigger } from './form-types' +import type { + FormModel, + FormNamePath, + FormRule, + FormValidateError, + FormValidateMessages, + FormValidateTrigger, +} from './form-types' -export function getValueByPath(model: FormModel, path: string): any { - if (!path) { +export function normalizeNamePath(path?: FormNamePath): Array { + if (path === undefined || path === null || path === '') { + return [] + } + if (Array.isArray(path)) { + return [...path] + } + if (typeof path === 'number') { + return [path] + } + return path + .replace(/\[(\w+)\]/g, '.$1') + .split('.') + .filter(Boolean) +} + +export function getPathKey(path?: FormNamePath): string { + return normalizeNamePath(path).join('.') +} + +export function getValueByPath(model: FormModel, path?: FormNamePath): any { + const keys = normalizeNamePath(path) + if (keys.length === 0) { return undefined } - return path.split('.').reduce((value, key) => value?.[key], model) + return keys.reduce((value, key) => value?.[key], model) } -export function setValueByPath(model: FormModel, path: string, value: any): void { - const keys = path.split('.') +export function setValueByPath(model: FormModel, path: FormNamePath, value: any): void { + const keys = normalizeNamePath(path) const lastKey = keys.pop() - if (!lastKey) { + if (lastKey === undefined) { return } const target = keys.reduce((current, key) => { @@ -22,6 +50,20 @@ export function setValueByPath(model: FormModel, path: string, value: any): void target[lastKey] = value } +export function cloneValue(value: T): T { + if (value === undefined || value === null) { + return value + } + if (typeof structuredClone === 'function') { + try { + return structuredClone(value) + } catch { + // Vue reactive proxies are not structured-cloneable in jsdom. + } + } + return JSON.parse(JSON.stringify(value)) +} + export function normalizeRules(rules?: FormRule | FormRule[]): FormRule[] { if (!rules) { return [] @@ -51,6 +93,12 @@ function getTypeValid(rule: FormRule, value: any): boolean { if (isEmptyValue(value) || !rule.type) { return true } + if (rule.type === 'email') { + return typeof value === 'string' && /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(value) + } + if (rule.type === 'url') { + return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value) + } if (rule.type === 'array') { return Array.isArray(value) } @@ -70,43 +118,145 @@ function getSize(value: any): number | undefined { return undefined } +const defaultValidateMessages: FormValidateMessages = { + default: '${label} is invalid', + required: '${label} is required', + enum: '${label} must be one of ${enum}', + whitespace: '${label} cannot be empty', + pattern: '${label} is invalid', + types: { + string: '${label} is not a valid string', + number: '${label} is not a valid number', + boolean: '${label} is not a valid boolean', + array: '${label} is not a valid array', + object: '${label} is not a valid object', + email: '${label} is not a valid email', + url: '${label} is not a valid url', + }, + string: { + len: '${label} must be ${len} characters', + min: '${label} must be at least ${min} characters', + max: '${label} must be up to ${max} characters', + range: '${label} must be between ${min} and ${max} characters', + }, + number: { + len: '${label} must equal ${len}', + min: '${label} must be at least ${min}', + max: '${label} must be up to ${max}', + range: '${label} must be between ${min} and ${max}', + }, + array: { + len: '${label} must contain ${len} items', + min: '${label} must contain at least ${min} items', + max: '${label} must contain up to ${max} items', + range: '${label} must contain between ${min} and ${max} items', + }, +} + +function formatMessage(template: string, values: Record): string { + return template.replace(/\$\{(\w+)\}/g, (_, key) => String(values[key] ?? '')) +} + +function getMessage( + rule: FormRule, + field: string, + label: string, + validateMessages: FormValidateMessages, + type?: keyof FormValidateMessages, + subType?: string, +): string { + if (rule.message) { + return rule.message + } + const messages = { + ...defaultValidateMessages, + ...validateMessages, + types: { ...defaultValidateMessages.types, ...validateMessages.types }, + string: { ...defaultValidateMessages.string, ...validateMessages.string }, + number: { ...defaultValidateMessages.number, ...validateMessages.number }, + array: { ...defaultValidateMessages.array, ...validateMessages.array }, + } + const groupedMessage = + subType && type && typeof messages[type] === 'object' + ? (messages[type] as Record)?.[subType] + : undefined + const template = + groupedMessage || + (type === 'types' && rule.type ? messages.types?.[rule.type] : undefined) || + (type ? (messages[type] as string | undefined) : undefined) || + messages.default || + '${label} is invalid' + + return formatMessage(template, { + label: label || field, + name: field, + field, + enum: rule.enum?.join(', '), + len: rule.len, + min: rule.min, + max: rule.max, + }) +} + +function getSizeMessageType(rule: FormRule, value: any): 'string' | 'number' | 'array' { + if (rule.type === 'array' || Array.isArray(value)) { + return 'array' + } + if (typeof value === 'number') { + return 'number' + } + return 'string' +} + export async function validateRule( rule: FormRule, value: any, model: FormModel, field: string, + label = field, + validateMessages: FormValidateMessages = {}, ): Promise { - const fallbackMessage = rule.message || `${field} is invalid` - if (rule.required && isEmptyValue(value)) { - return { field, message: rule.message || `${field} is required` } + return { field, message: getMessage(rule, field, label, validateMessages, 'required') } + } + + if (rule.whitespace && typeof value === 'string' && value.trim() === '') { + return { field, message: getMessage(rule, field, label, validateMessages, 'whitespace') } + } + + if (rule.enum && !isEmptyValue(value) && !rule.enum.includes(value)) { + return { field, message: getMessage(rule, field, label, validateMessages, 'enum') } } if (!getTypeValid(rule, value)) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages, 'types') } } const size = getSize(value) if (size !== undefined) { + const sizeMessageType = getSizeMessageType(rule, value) if (rule.len !== undefined && size !== rule.len) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages, sizeMessageType, 'len') } + } + if (rule.min !== undefined && rule.max !== undefined && (size < rule.min || size > rule.max)) { + return { field, message: getMessage(rule, field, label, validateMessages, sizeMessageType, 'range') } } if (rule.min !== undefined && size < rule.min) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages, sizeMessageType, 'min') } } if (rule.max !== undefined && size > rule.max) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages, sizeMessageType, 'max') } } } if (rule.pattern && !rule.pattern.test(String(value))) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages, 'pattern') } } if (rule.validator) { const result = await rule.validator(rule, value, model) if (result === false) { - return { field, message: fallbackMessage } + return { field, message: getMessage(rule, field, label, validateMessages) } } if (typeof result === 'string') { return { field, message: result } diff --git a/packages/ccui/ui/form/test/form.test.ts b/packages/ccui/ui/form/test/form.test.ts index 6ec71eaa..dc0aff55 100644 --- a/packages/ccui/ui/form/test/form.test.ts +++ b/packages/ccui/ui/form/test/form.test.ts @@ -14,6 +14,7 @@ function getFormVm(wrapper: VueWrapper) { validateField: (prop: string | string[]) => Promise resetFields: (prop?: string | string[]) => void clearValidate: (prop?: string | string[]) => void + scrollToField: (prop: string | string[]) => void } } @@ -172,4 +173,542 @@ describe('form', () => { expect(wrapper.emitted('submit')?.[0][0]).toBe(false) expect(wrapper.emitted('validate-failed')?.length).toBe(1) }) + + it('supports name path arrays, initial values, and custom validate messages', async () => { + const model = reactive<{ users?: Array<{ email?: string }> }>({}) + const wrapper = mount(Form, { + props: { + model, + initialValues: { users: [{ email: 'initial@example.com' }] }, + validateMessages: { + types: { + email: '${label} format error', + }, + }, + }, + slots: { + default: () => + h(FormItem, { label: 'Email', name: ['users', 0, 'email'], rules: { type: 'email' } }, () => h('input')), + }, + }) + + await nextTick() + expect(model.users?.[0].email).toBe('initial@example.com') + + model.users![0].email = 'broken' + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.find(itemNs.e('message')).text()).toBe('Email format error') + + getFormVm(wrapper).resetFields() + expect(model.users?.[0].email).toBe('initial@example.com') + }) + + it('revalidates fields when dependencies change', async () => { + const model = reactive({ password: 'one', confirm: 'one' }) + const wrapper = mount(Form, { + props: { model }, + slots: { + default: () => [ + h(FormItem, { prop: 'password' }, () => h('input')), + h( + FormItem, + { + prop: 'confirm', + dependencies: ['password'], + rules: { + validator: (_rule: unknown, value: string) => value === model.password || 'Passwords differ', + }, + }, + () => h('input'), + ), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validateField('confirm')).resolves.toBe(true) + model.password = 'two' + await nextTick() + await Promise.resolve() + await nextTick() + expect(wrapper.find(itemNs.e('message')).text()).toBe('Passwords differ') + }) + + it('supports layout props, extra text, hidden items, no-style items, and scrolling to fields', async () => { + const scrollIntoView = vi.fn() + Element.prototype.scrollIntoView = scrollIntoView + + const wrapper = mount(Form, { + props: { + model: { name: '' }, + layout: 'vertical', + requiredMark: 'optional', + scrollToFirstError: true, + }, + slots: { + default: () => [ + h(FormItem, { label: 'Name', prop: 'name', rules: { required: true }, extra: 'Visible hint' }, () => + h('input'), + ), + h(FormItem, { label: 'Nickname', prop: 'nickname', hidden: true }, () => h('input')), + h(FormItem, { prop: 'raw', noStyle: true }, () => h('input')), + ], + }, + }) + + await nextTick() + expect(wrapper.classes()).toContain(formNs.m('vertical').slice(1)) + expect(wrapper.find(itemNs.e('extra')).text()).toBe('Visible hint') + expect(wrapper.findAllComponents(FormItem)[1].classes()).toContain(itemNs.m('hidden').slice(1)) + expect(wrapper.findAllComponents(FormItem)[2].classes()).toContain(itemNs.m('no-style').slice(1)) + + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(scrollIntoView).toHaveBeenCalled() + }) + + it('renders top label position without fixed label width', () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + labelWidth: 120, + labelPosition: 'top', + }, + slots: { + default: () => h(FormItem, { label: 'Name', prop: 'name' }, () => h('input')), + }, + }) + + expect(wrapper.classes()).toContain(formNs.m('label-top').slice(1)) + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('top').slice(1)) + expect(wrapper.find(itemNs.e('label')).attributes('style')).toBeUndefined() + }) + + it('renders inline layout class', () => { + const wrapper = mount(Form, { + props: { + model: { keyword: '' }, + layout: 'inline', + }, + slots: { + default: () => h(FormItem, { label: 'Keyword', prop: 'keyword' }, () => h('input')), + }, + }) + + expect(wrapper.classes()).toContain(formNs.m('inline').slice(1)) + }) + + it('supports disabling required mark from form', () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + requiredMark: false, + }, + slots: { + default: () => h(FormItem, { label: 'Name', prop: 'name', required: true }, () => h('input')), + }, + }) + + expect(wrapper.findComponent(FormItem).classes()).not.toContain(itemNs.m('required').slice(1)) + }) + + it('renders optional mark for non-required fields', () => { + const wrapper = mount(Form, { + props: { + model: { nickname: '' }, + requiredMark: 'optional', + }, + slots: { + default: () => h(FormItem, { label: 'Nickname', prop: 'nickname' }, () => h('input')), + }, + }) + + expect(wrapper.find(itemNs.e('optional')).text()).toBe('(optional)') + }) + + it('supports form and item colon configuration', () => { + const wrapper = mount(Form, { + props: { + model: { first: '', second: '' }, + colon: false, + }, + slots: { + default: () => [ + h(FormItem, { label: 'First', prop: 'first' }, () => h('input')), + h(FormItem, { label: 'Second', prop: 'second', colon: true }, () => h('input')), + ], + }, + }) + + const labels = wrapper.findAll(itemNs.e('label')) + expect(labels[0].classes()).not.toContain(itemNs.em('label', 'colon').slice(1)) + expect(labels[1].classes()).toContain(itemNs.em('label', 'colon').slice(1)) + }) + + it('forwards htmlFor to the label', () => { + const wrapper = mount(Form, { + props: { + model: { email: '' }, + }, + slots: { + default: () => h(FormItem, { label: 'Email', prop: 'email', htmlFor: 'email-input' }, () => h('input')), + }, + }) + + expect(wrapper.find(itemNs.e('label')).attributes('for')).toBe('email-input') + }) + + it('uses external validateStatus and help text', () => { + const wrapper = mount(Form, { + props: { + model: { email: '' }, + }, + slots: { + default: () => + h(FormItem, { label: 'Email', prop: 'email', validateStatus: 'error', help: 'External error' }, () => + h('input'), + ), + }, + }) + + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('error').slice(1)) + expect(wrapper.find(itemNs.e('message')).text()).toBe('External error') + }) + + it('clears validation state for all fields', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '', age: '' }, + rules: { + name: { required: true, message: 'Missing name' }, + age: { required: true, message: 'Missing age' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'name' }, () => h('input')), + h(FormItem, { prop: 'age' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).length).toBe(2) + + getFormVm(wrapper).clearValidate() + await nextTick() + expect(wrapper.findAll(itemNs.e('message')).length).toBe(0) + }) + + it('resets only selected fields', async () => { + const model = reactive({ name: 'Initial', age: '18' }) + const wrapper = mount(Form, { + props: { model }, + slots: { + default: () => [ + h(FormItem, { prop: 'name' }, () => h('input')), + h(FormItem, { prop: 'age' }, () => h('input')), + ], + }, + }) + + await nextTick() + model.name = 'Changed' + model.age = '20' + getFormVm(wrapper).resetFields('name') + + expect(model.name).toBe('Initial') + expect(model.age).toBe('20') + }) + + it('validates multiple selected fields', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '', age: '', city: '' }, + rules: { + name: { required: true, message: 'Missing name' }, + age: { required: true, message: 'Missing age' }, + city: { required: true, message: 'Missing city' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'name' }, () => h('input')), + h(FormItem, { prop: 'age' }, () => h('input')), + h(FormItem, { prop: 'city' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validateField(['name', 'age'])).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual(['Missing name', 'Missing age']) + }) + + it('emits submit with true when validation passes', async () => { + const wrapper = mount(Form, { + props: { + model: { name: 'Ready' }, + rules: { + name: { required: true, message: 'Missing name' }, + }, + }, + slots: { + default: () => h(FormItem, { prop: 'name' }, () => h('button', { type: 'submit' }, 'Submit')), + }, + }) + + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(wrapper.emitted('submit')?.[0][0]).toBe(true) + expect(wrapper.emitted('validate-failed')).toBeUndefined() + }) + + it('returns true for fields without prop or rules', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + }, + slots: { + default: () => [h(FormItem, null, () => h('input')), h(FormItem, { prop: 'name' }, () => h('input'))], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(true) + }) + + it('merges form rules and item rules', async () => { + const wrapper = mount(Form, { + props: { + model: { code: 'ab' }, + rules: { + code: { pattern: /^\d+$/, message: 'Digits only' }, + }, + }, + slots: { + default: () => h(FormItem, { prop: 'code', rules: { min: 3, message: 'Too short' } }, () => h('input')), + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.find(itemNs.e('message')).text()).toBe('Digits only') + }) + + it('clears validation when rules change with validateOnRuleChange', async () => { + const rules = reactive({ + name: { required: true, message: 'Missing name' }, + }) + const wrapper = mount(Form, { + props: { + model: { name: '' }, + rules, + }, + slots: { + default: () => h(FormItem, { prop: 'name' }, () => h('input')), + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.find(itemNs.e('message')).exists()).toBe(true) + + await wrapper.setProps({ rules: { name: { required: true, message: 'Still missing' } } }) + expect(wrapper.find(itemNs.e('message')).exists()).toBe(false) + }) + + it('keeps validation state when validateOnRuleChange is false', async () => { + const wrapper = mount(Form, { + props: { + model: { name: '' }, + rules: { name: { required: true, message: 'Missing name' } }, + validateOnRuleChange: false, + }, + slots: { + default: () => h(FormItem, { prop: 'name' }, () => h('input')), + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + await wrapper.setProps({ rules: { name: { required: true, message: 'Still missing' } } }) + expect(wrapper.find(itemNs.e('message')).text()).toBe('Missing name') + }) + + it('validates primitive and collection types', async () => { + const wrapper = mount(Form, { + props: { + model: { + title: 1, + count: '1', + enabled: 'yes', + tags: 'tag', + profile: ['invalid'], + }, + rules: { + title: { type: 'string', message: 'Title type error' }, + count: { type: 'number', message: 'Count type error' }, + enabled: { type: 'boolean', message: 'Enabled type error' }, + tags: { type: 'array', message: 'Tags type error' }, + profile: { type: 'object', message: 'Profile type error' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'title' }, () => h('input')), + h(FormItem, { prop: 'count' }, () => h('input')), + h(FormItem, { prop: 'enabled' }, () => h('input')), + h(FormItem, { prop: 'tags' }, () => h('input')), + h(FormItem, { prop: 'profile' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual([ + 'Title type error', + 'Count type error', + 'Enabled type error', + 'Tags type error', + 'Profile type error', + ]) + }) + + it('validates email and url types', async () => { + const wrapper = mount(Form, { + props: { + model: { email: 'broken', site: 'ftp://example.com' }, + rules: { + email: { type: 'email', message: 'Email invalid' }, + site: { type: 'url', message: 'Url invalid' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'email' }, () => h('input')), + h(FormItem, { prop: 'site' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual(['Email invalid', 'Url invalid']) + }) + + it('validates enum and whitespace rules', async () => { + const wrapper = mount(Form, { + props: { + model: { role: 'guest', title: ' ' }, + rules: { + role: { enum: ['admin', 'user'], message: 'Role invalid' }, + title: { whitespace: true, message: 'Title blank' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'role' }, () => h('input')), + h(FormItem, { prop: 'title' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual(['Role invalid', 'Title blank']) + }) + + it('uses validate message templates for length and range checks', async () => { + const wrapper = mount(Form, { + props: { + model: { code: 'ab', score: 8, tags: ['one', 'two', 'three'] }, + rules: { + code: { len: 4 }, + score: { type: 'number', min: 10, max: 20 }, + tags: { type: 'array', max: 2 }, + }, + validateMessages: { + string: { len: '${label} length must be ${len}' }, + number: { range: '${label} must be ${min}-${max}' }, + array: { max: '${label} max ${max}' }, + }, + }, + slots: { + default: () => [ + h(FormItem, { label: 'Code', prop: 'code' }, () => h('input')), + h(FormItem, { label: 'Score', prop: 'score' }, () => h('input')), + h(FormItem, { label: 'Tags', prop: 'tags' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual([ + 'Code length must be 4', + 'Score must be 10-20', + 'Tags max 2', + ]) + }) + + it('supports validators returning false and Error', async () => { + const wrapper = mount(Form, { + props: { + model: { first: 'bad', second: 'bad' }, + rules: { + first: { message: 'First invalid', validator: () => false }, + second: { validator: () => new Error('Second invalid') }, + }, + }, + slots: { + default: () => [ + h(FormItem, { prop: 'first' }, () => h('input')), + h(FormItem, { prop: 'second' }, () => h('input')), + ], + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findAll(itemNs.e('message')).map((item) => item.text())).toEqual(['First invalid', 'Second invalid']) + }) + + it('validates hidden fields while keeping them hidden', async () => { + const wrapper = mount(Form, { + props: { + model: { token: '' }, + rules: { + token: { required: true, message: 'Token missing' }, + }, + }, + slots: { + default: () => h(FormItem, { prop: 'token', hidden: true }, () => h('input')), + }, + }) + + await nextTick() + await expect(getFormVm(wrapper).validate()).resolves.toBe(false) + expect(wrapper.findComponent(FormItem).classes()).toContain(itemNs.m('hidden').slice(1)) + expect(wrapper.find(itemNs.e('message')).text()).toBe('Token missing') + }) + + it('scrolls to a field through exposed scrollToField', async () => { + const scrollIntoView = vi.fn() + Element.prototype.scrollIntoView = scrollIntoView + const wrapper = mount(Form, { + props: { + model: { name: '' }, + }, + slots: { + default: () => h(FormItem, { prop: 'name' }, () => h('input')), + }, + }) + + await nextTick() + getFormVm(wrapper).scrollToField('name') + expect(scrollIntoView).toHaveBeenCalled() + }) }) diff --git a/packages/docs/components/form/index.md b/packages/docs/components/form/index.md index 7b6a18b2..12dd2e68 100644 --- a/packages/docs/components/form/index.md +++ b/packages/docs/components/form/index.md @@ -1,6 +1,6 @@ # Form 表单 -用于收集、校验和提交一组输入项。 +用于收集、校验和提交一组输入项。当前版本目标覆盖 Ant Design Form 约 80% 的高频能力,保留少量大型高级能力后续迭代。 ## 基本用法 @@ -26,7 +26,7 @@ const cityOptions = [ - + @@ -50,7 +50,7 @@ const model = reactive({ const rules = { email: [ { required: true, message: 'Email is required', trigger: 'blur' }, - { pattern: /@/, message: 'Email is invalid', trigger: 'change' }, + { type: 'email', message: 'Email is invalid', trigger: 'change' }, ], } @@ -60,7 +60,7 @@ function submit() {