diff --git a/.github/workflows/buildComponents.yml b/.github/workflows/buildComponents.yml index bf504868..3dc30889 100644 --- a/.github/workflows/buildComponents.yml +++ b/.github/workflows/buildComponents.yml @@ -18,8 +18,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 @@ -27,8 +25,10 @@ jobs: node-version: ${{ matrix.node-version }} cache: pnpm + # pnpm install 的 postinstall 会自动 bootstrap 前置产物 + # (icons dist + theme.scss),无需在此单独构建。 - name: Install dependencies run: pnpm install - name: Run build:components - run: pnpm --filter cli build:components + run: pnpm --filter ccui-cli build:components diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5ea6029f..7c8cac7f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,6 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: @@ -21,8 +20,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36fff284..5a9f2ffc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 @@ -27,8 +25,10 @@ jobs: node-version: ${{ matrix.node-version }} cache: pnpm + # pnpm install 的 postinstall 会自动 bootstrap 前置产物 + # (icons dist + theme.scss),无需在此单独构建。 - name: Install dependencies run: pnpm install - name: Run tests - run: pnpm --filter vue3-ccui test + run: pnpm --filter @vaebe/ccui test diff --git a/.gitignore b/.gitignore index 59a11056..ea0781f7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,11 +31,14 @@ packages/ccui/build **/.vitepress/config/enSidebar.ts **/.vitepress/config.ts.js **/.vitepress/cache +packages/docs/public/llms.txt +packages/docs/public/llms-full.txt -**CHANGELOG.md +packages/*/CHANGELOG.md # test packages/ccui/coverage packages/ccui/ui/**/__snapshots__ .pnpm-debug.log .claude -.codeflicker \ No newline at end of file +.codeflicker +docs-notes/screenshots \ No newline at end of file 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 - 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 + + diff --git a/examples/consumer/package.json b/examples/consumer/package.json new file mode 100644 index 00000000..4c2bbcd9 --- /dev/null +++ b/examples/consumer/package.json @@ -0,0 +1,24 @@ +{ + "name": "consumer-fixture", + "version": "0.0.0", + "private": true, + "description": "End-to-end consumer fixture for @vaebe/ccui — exercises the three published consumption paths (main + tree-shake, subpath, unplugin resolver auto-import).", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@vaebe/ccui": "workspace:*", + "vue": "3.5.33" + }, + "devDependencies": { + "@vaebe/unplugin-vue-components-ccui": "workspace:*", + "@vitejs/plugin-vue": "^6.0.6", + "typescript": "^5.9.3", + "unplugin-vue-components": "^28.0.0", + "vite": "catalog:", + "vue-tsc": "^3.2.7" + } +} diff --git a/examples/consumer/src/App.vue b/examples/consumer/src/App.vue new file mode 100644 index 00000000..4643784f --- /dev/null +++ b/examples/consumer/src/App.vue @@ -0,0 +1,40 @@ + + + diff --git a/examples/consumer/src/main.ts b/examples/consumer/src/main.ts new file mode 100644 index 00000000..2f8c0944 --- /dev/null +++ b/examples/consumer/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import App from './App.vue' + +// Side-effect imports — make sure each consumption path's module is part of the +// graph even if its named exports aren't referenced from `App.vue`. The build +// log + bundle inspection then confirms all three resolve cleanly. +import './page-main' +import './page-subpath' +import './page-resolver' + +createApp(App).mount('#app') diff --git a/examples/consumer/src/page-main.ts b/examples/consumer/src/page-main.ts new file mode 100644 index 00000000..633fe557 --- /dev/null +++ b/examples/consumer/src/page-main.ts @@ -0,0 +1,8 @@ +// Path 1 — main package + tree-shake. +// Single root import; bundler tree-shakes unused exports based on the package +// `sideEffects: ['**/*.css', '**/*.scss']` whitelist. CSS comes from the one +// global bundle. +import { Button, Modal } from '@vaebe/ccui' +import '@vaebe/ccui/style.css' + +export { Button, Modal } diff --git a/examples/consumer/src/page-resolver.ts b/examples/consumer/src/page-resolver.ts new file mode 100644 index 00000000..e351e97f --- /dev/null +++ b/examples/consumer/src/page-resolver.ts @@ -0,0 +1,6 @@ +// Path 3 — unplugin-vue-components resolver (auto-import + auto sideEffect CSS). +// No imports of components or styles needed at the call site; `` in a +// template is transformed to `import { Button } from '@vaebe/ccui'` plus the +// matching `import '@vaebe/ccui/style.css'` side-effect. See `App.vue` for the +// template-side usage. +export {} diff --git a/examples/consumer/src/page-subpath.ts b/examples/consumer/src/page-subpath.ts new file mode 100644 index 00000000..a6f3789f --- /dev/null +++ b/examples/consumer/src/page-subpath.ts @@ -0,0 +1,7 @@ +// Path 2 — explicit subpath import. +// Pulls only the component's own ESM chunk + its scoped style. Useful when the +// consumer wants zero reliance on tree-shake correctness. +import { Button } from '@vaebe/ccui/button' +import '@vaebe/ccui/button/style.css' + +export { Button } diff --git a/examples/consumer/tsconfig.json b/examples/consumer/tsconfig.json new file mode 100644 index 00000000..dd582cde --- /dev/null +++ b/examples/consumer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "useDefineForClassFields": true, + "allowSyntheticDefaultImports": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/examples/consumer/vite.config.ts b/examples/consumer/vite.config.ts new file mode 100644 index 00000000..adb88b30 --- /dev/null +++ b/examples/consumer/vite.config.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { Vue3CCUIResolver } from '@vaebe/unplugin-vue-components-ccui' + +/** + * Consumer fixture vite config. + * + * The workspace `@vaebe/ccui` package symlink points at `packages/ccui/` source, + * which has no subpath `exports` map. The actual published artifact lives in + * `packages/ccui/build/`. We can't reuse the build package.json's `exports` + * field via alias (alias is pure string prefix replacement and ignores `exports` + * resolution), so we mirror what the published exports map declares: main → + * `vue-ccui.es.js`, `./` → `.//index.es.js`, `*.css` / `*.scss` + * assets keep their literal path. This exercises the *as-published* resolution + * behaviour (subpath modules, sideEffects-aware CSS, tree-shake) end-to-end. + */ +const ccuiBuild = fileURLToPath(new URL('../../packages/ccui/build', import.meta.url)) + +export default defineConfig({ + resolve: { + alias: [ + // 资产路径(style.css / scss / theme 等)保持字面,order 必须在 subpath 前匹配 + { find: /^@vaebe\/ccui\/(.+\.(css|scss))$/, replacement: `${ccuiBuild}/$1` }, + // 主入口 + { find: /^@vaebe\/ccui$/, replacement: `${ccuiBuild}/vue-ccui.es.js` }, + // 单组件 subpath → 显式指向 ESM 入口(已发布场景下走主 package.json exports map) + { find: /^@vaebe\/ccui\/([^/]+)$/, replacement: `${ccuiBuild}/$1/index.es.js` }, + ], + }, + plugins: [ + vue(), + Components({ + dts: 'components.d.ts', + resolvers: [Vue3CCUIResolver({ importStyle: 'css' })], + }), + ], +}) diff --git a/package.json b/package.json index a6e01a36..89e08f54 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,36 @@ { "name": "vue-ccui", + "license": "MIT", + "type": "module", "scripts": { - "dev": "pnpm --filter docs dev", - "docs:build": "pnpm --filter docs predev && pnpm --filter docs docs:build", - "lint": "eslint . --fix", - "postinstall": "husky install" + "dev": "vp run --filter docs dev", + "docs:build": "vp run --filter docs predev && vp run --filter docs docs:build", + "build:lib": "vp run --filter ccui-cli build:lib", + "release": "node scripts/publish.mjs", + "release:dry": "node scripts/publish.mjs --dry-run", + "check": "vp check", + "test": "vp test", + "lint": "vp lint . --fix", + "fmt": "vp fmt", + "prepare": "vp config", + "bootstrap": "pnpm --filter @vaebe/ccui-icons build && pnpm --filter ccui-cli generate:theme", + "postinstall": "pnpm bootstrap" }, "devDependencies": { - "@antfu/eslint-config": "^6.2.0", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@ls-lint/ls-lint": "^2.3.1", - "@typescript-eslint/eslint-plugin": "^8.46.3", - "@typescript-eslint/parser": "^8.46.3", "bumpp": "^10.3.1", "commitlint": "^20.1.0", - "eslint": "^9.39.1", - "eslint-plugin-format": "^1.0.2", - "husky": "^9.1.7", - "lint-staged": "^16.2.6" + "vite-plus": "catalog:" }, - "lint-staged": { - "packages/**/*.{vue,js,jsx,ts,tsx,scss,css}": [ - "eslint --fix" - ] + "engines": { + "node": ">=22.10.0" }, - "license": "MIT", + "packageManager": "pnpm@10.33.2", "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", - "esbuild", "vue-demi" ] } diff --git a/packages/ccui/README.md b/packages/ccui/README.md index c1e30c95..83329138 100644 --- a/packages/ccui/README.md +++ b/packages/ccui/README.md @@ -1,5 +1,9 @@ # 简介 +vue3-ccui 是基于 Vue 3 + TypeScript 的现代组件库,提供完整 Design Token 系统与工业级视觉规范。覆盖七大类 84 个组件 / 工具入口,内置完整 i18n(zhCN / enUS / jaJP / koKR)与 ARIA 审计。 + +升级指南、组件文档与设计 Token 详见 [文档站](https://vaebe.github.io/ccui)。 + [从零到一建立属于自己的前端组件库](https://juejin.cn/post/7124487017588588574) ## 快速开始 @@ -8,24 +12,24 @@ ```shell # NPM -$ npm install vue3-ccui --save +$ npm install @vaebe/ccui --save # Yarn -$ yarn add vue3-ccui +$ yarn add @vaebe/ccui # pnpm -$ pnpm install vue3-ccui +$ pnpm install @vaebe/ccui ``` ### 完整引入 ```ts import { createApp } from 'vue' -import ccui from 'vue3-ccui' +import ccui from '@vaebe/ccui' import App from './App.vue' import './style.css' -import 'vue3-ccui/style.css' +import '@vaebe/ccui/style.css' createApp(App).use(ccui).mount('#app') ``` diff --git a/packages/ccui/env.d.ts b/packages/ccui/env.d.ts new file mode 100644 index 00000000..4e50603d --- /dev/null +++ b/packages/ccui/env.d.ts @@ -0,0 +1,11 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, unknown> + export default component +} + +declare module '*.scss' +declare module '*.css' +declare module '*.svg' +declare module 'virtual:uno.css' +declare module '@vitepress-code-preview/container/dist/style.css' diff --git a/packages/ccui/index.d.ts b/packages/ccui/index.d.ts index 666f4314..c8826793 100644 --- a/packages/ccui/index.d.ts +++ b/packages/ccui/index.d.ts @@ -1,13 +1,13 @@ -import { VNode } from 'vue'; +import { VNode } from 'vue' declare global { namespace JSX { interface Element extends VNode {} interface ElementClass { - $props: any; + $props: any } interface IntrinsicElements { - [elem: string]: any; + [elem: string]: any } } } diff --git a/packages/ccui/package.json b/packages/ccui/package.json index 113b88f1..445a97ec 100644 --- a/packages/ccui/package.json +++ b/packages/ccui/package.json @@ -1,53 +1,75 @@ { - "name": "vue3-ccui", - "version": "1.2.2", - "license": "MIT", + "name": "@vaebe/ccui", + "version": "2.0.0", "description": "vue3-ccui components based on Vite and Vue3", - "type": "module", "keywords": [ + "components", "frontend", + "jsx", "typescript", "ui-component", - "components", - "vue-components", + "vite", "vue", + "vue-components", "vue3", - "vite", - "jsx", "vue3-ccui" ], "homepage": "https://vaebe.github.io/ccui/", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/vaebe/ccui.git" }, + "style": "style.css", + "files": [ + "*.js", + "*.css", + "*.d.ts", + "theme", + "**/index.d.ts", + "README.md", + "LICENSE" + ], + "type": "module", "main": "vue-ccui.umd.js", "module": "vue-ccui.es.js", - "style": "style.css", + "types": "index.d.ts", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "tag": "latest" + }, "scripts": { - "test": "vitest run", - "test:watch": "vitest", - "coverage": "vitest run --coverage" + "test": "vp test run", + "test:watch": "vp test", + "coverage": "vp test run --coverage" }, "dependencies": { "@floating-ui/dom": "^1.7.4", "@floating-ui/vue": "^1.1.9", + "@iconify/vue": "^5.0.1", + "@vaebe/ccui-icons": "workspace:*", "dayjs": "^1.11.19", - "theme": "workspace:*", - "vue": "^3.5.22" + "qrcode-generator": "^2.0.4" }, "devDependencies": { "@types/node": "^22.19.0", - "@vitejs/plugin-vue": "^6.0.1", - "@vitejs/plugin-vue-jsx": "^5.1.1", - "@vitest/coverage-v8": "4.0.6", + "@vaebe/ccui-theme": "workspace:*", + "@vitejs/plugin-vue": "^6.0.6", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "@vitest/coverage-v8": "^4.1.5", "@vue/test-utils": "^2.4.6", + "csstype": "^3.1.3", "jsdom": "^24.1.3", "sass": "^1.93.3", "typescript": "^5.9.3", - "vite": "^7.1.12", - "vite-svg-loader": "^5.1.0", - "vitest": "^4.0.6", - "vue-tsc": "^3.1.3" + "vite": "catalog:", + "vite-plus": "catalog:", + "vite-svg-loader": "^5.1.1", + "vitest": "catalog:", + "vue-tsc": "^3.2.7" + }, + "peerDependencies": { + "vue": "^3.5.0" } } diff --git a/packages/ccui/tsconfig.build.json b/packages/ccui/tsconfig.build.json new file mode 100644 index 00000000..01a00960 --- /dev/null +++ b/packages/ccui/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "build/types", + "declarationDir": "build/types", + "rootDir": ".", + "noEmit": false, + "composite": false, + "skipLibCheck": true + }, + "include": ["ui/**/*.ts", "ui/**/*.tsx", "ui/**/*.vue", "./index.d.ts", "./env.d.ts"], + "exclude": [ + "node_modules", + "build", + "dist", + "coverage", + "**/*.test.ts", + "**/*.test.tsx", + "**/test/**", + "**/__tests__/**" + ] +} diff --git a/packages/ccui/tsconfig.json b/packages/ccui/tsconfig.json index e4895c1b..fed6d8b7 100644 --- a/packages/ccui/tsconfig.json +++ b/packages/ccui/tsconfig.json @@ -14,11 +14,5 @@ "skipLibCheck": true }, "references": [{ "path": "./tsconfig.node.json" }], - "include": [ - "ui/**/*.ts", - "ui/**/*.d.ts", - "ui/**/*.tsx", - "ui/**/*.vue", - "./index.d.ts" - ] + "include": ["ui/**/*.ts", "ui/**/*.d.ts", "ui/**/*.tsx", "ui/**/*.vue", "./index.d.ts", "./env.d.ts"] } diff --git a/packages/ccui/tsconfig.node.json b/packages/ccui/tsconfig.node.json index 104126ac..ba56f580 100644 --- a/packages/ccui/tsconfig.node.json +++ b/packages/ccui/tsconfig.node.json @@ -5,7 +5,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": [ - "./vite.config.ts" - ] + "include": ["./vite.config.ts"] } diff --git a/packages/ccui/ui/affix/index.ts b/packages/ccui/ui/affix/index.ts new file mode 100644 index 00000000..f1478b14 --- /dev/null +++ b/packages/ccui/ui/affix/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Affix from './src/affix' + +Affix.install = function (app: App): void { + app.component(Affix.name!, Affix) +} + +export { Affix } + +export default { + title: 'Affix 固钉', + category: '导航', + status: '100%', + install(app: App): void { + app.component(Affix.name!, Affix) + }, +} diff --git a/packages/ccui/ui/affix/src/affix-types.ts b/packages/ccui/ui/affix/src/affix-types.ts new file mode 100644 index 00000000..2b35ce14 --- /dev/null +++ b/packages/ccui/ui/affix/src/affix-types.ts @@ -0,0 +1,22 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export const affixProps = { + offsetTop: { + type: Number, + default: undefined, + }, + offsetBottom: { + type: Number, + default: undefined, + }, + target: { + type: [String, Object, Function] as PropType HTMLElement | Window | null)>, + default: undefined, + }, + zIndex: { + type: Number, + default: 10, + }, +} as const + +export type AffixProps = ExtractPropTypes diff --git a/packages/ccui/ui/affix/src/affix.scss b/packages/ccui/ui/affix/src/affix.scss new file mode 100644 index 00000000..7cee82f4 --- /dev/null +++ b/packages/ccui/ui/affix/src/affix.scss @@ -0,0 +1,9 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-affix { + &__inner { + &--fixed { + position: fixed; + } + } +} diff --git a/packages/ccui/ui/affix/src/affix.tsx b/packages/ccui/ui/affix/src/affix.tsx new file mode 100644 index 00000000..074addea --- /dev/null +++ b/packages/ccui/ui/affix/src/affix.tsx @@ -0,0 +1,178 @@ +import type { CSSProperties } from 'vue' +import type { AffixProps } from './affix-types' +import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { affixProps } from './affix-types' +import './affix.scss' + +function resolveTarget(target: AffixProps['target']): HTMLElement | Window { + if (!target) { + return window + } + if (typeof target === 'string') { + const el = document.querySelector(target) + return (el as HTMLElement) ?? window + } + if (typeof target === 'function') { + const result = target() + return (result as HTMLElement | Window) ?? window + } + return target as HTMLElement +} + +function isWindow(target: HTMLElement | Window): target is Window { + return target === window +} + +function getOffsetRect(el: HTMLElement, container: HTMLElement | Window) { + const rect = el.getBoundingClientRect() + if (isWindow(container)) { + return { + top: rect.top, + bottom: window.innerHeight - rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + } + } + const containerEl = container as HTMLElement + if (typeof containerEl.getBoundingClientRect !== 'function') { + return { + top: rect.top, + bottom: window.innerHeight - rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + } + } + const containerRect = containerEl.getBoundingClientRect() + return { + top: rect.top - containerRect.top, + bottom: containerRect.bottom - rect.bottom, + left: rect.left - containerRect.left, + width: rect.width, + height: rect.height, + } +} + +export default defineComponent({ + name: 'CAffix', + props: affixProps, + emits: ['change'], + setup(props: AffixProps, { emit, slots }) { + const ns = useNamespace('affix') + const wrapperRef = ref() + const fixed = ref(false) + const placeholderStyle = ref({}) + const fixedStyle = ref({}) + + let container: HTMLElement | Window | null = null + let resizeObserver: ResizeObserver | null = null + + const isTopMode = computed(() => props.offsetBottom === undefined) + const offsetTop = computed(() => props.offsetTop ?? 0) + + const update = () => { + if (!wrapperRef.value || !container) { + return + } + const offset = getOffsetRect(wrapperRef.value, container) + const shouldFix = isTopMode.value ? offset.top <= offsetTop.value : offset.bottom <= (props.offsetBottom ?? 0) + + if (shouldFix) { + const targetIsWindow = isWindow(container) + const containerEl = targetIsWindow ? null : (container as HTMLElement) + const baseRect = + containerEl && typeof containerEl.getBoundingClientRect === 'function' + ? containerEl.getBoundingClientRect() + : null + const wrapperRect = wrapperRef.value.getBoundingClientRect() + + const style: CSSProperties = { + position: 'fixed', + width: `${offset.width}px`, + height: `${offset.height}px`, + zIndex: props.zIndex, + left: `${wrapperRect.left}px`, + } + if (isTopMode.value) { + style.top = targetIsWindow ? `${offsetTop.value}px` : `${(baseRect?.top ?? 0) + offsetTop.value}px` + } else { + style.bottom = targetIsWindow + ? `${props.offsetBottom ?? 0}px` + : `${window.innerHeight - (baseRect?.bottom ?? 0) + (props.offsetBottom ?? 0)}px` + } + fixedStyle.value = style + placeholderStyle.value = { + width: `${offset.width}px`, + height: `${offset.height}px`, + } + } else { + fixedStyle.value = {} + placeholderStyle.value = {} + } + + if (shouldFix !== fixed.value) { + fixed.value = shouldFix + emit('change', shouldFix) + } + } + + const bindContainer = () => { + container = resolveTarget(props.target) + container.addEventListener('scroll', update, { passive: true }) + // 当滚动容器不是 window 时,仍需监听窗口滚动以处理嵌套滚动场景 + if (!isWindow(container)) { + window.addEventListener('scroll', update, { passive: true }) + } + } + + const unbindContainer = () => { + container?.removeEventListener('scroll', update) + window.removeEventListener('scroll', update) + } + + onMounted(() => { + bindContainer() + window.addEventListener('resize', update) + if (typeof ResizeObserver !== 'undefined' && wrapperRef.value) { + resizeObserver = new ResizeObserver(() => update()) + resizeObserver.observe(wrapperRef.value) + } + // 等下一帧再计算,避免初次布局未完成 + requestAnimationFrame(() => update()) + }) + + onBeforeUnmount(() => { + unbindContainer() + window.removeEventListener('resize', update) + resizeObserver?.disconnect() + resizeObserver = null + }) + + watch( + () => props.target, + () => { + unbindContainer() + bindContainer() + update() + }, + ) + + watch( + () => [props.offsetTop, props.offsetBottom], + () => update(), + ) + + return () => ( +
+
+ {slots.default?.()} +
+
+ ) + }, +}) diff --git a/packages/ccui/ui/affix/test/affix.test.ts b/packages/ccui/ui/affix/test/affix.test.ts new file mode 100644 index 00000000..b4030da1 --- /dev/null +++ b/packages/ccui/ui/affix/test/affix.test.ts @@ -0,0 +1,143 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Affix } from '../index' + +const ns = useNamespace('affix', true) + +describe('affix', () => { + it('renders default content', () => { + const wrapper = mount(Affix, { + slots: { default: '' }, + }) + expect(wrapper.find('button').text()).toBe('Top') + expect(wrapper.find(ns.b()).exists()).toBe(true) + }) + + it('mounts with offsetTop prop', () => { + const wrapper = mount(Affix, { + props: { offsetTop: 50 }, + slots: { default: 'Pinned' }, + }) + expect(wrapper.find('span').text()).toBe('Pinned') + }) + + it('mounts in offsetBottom mode', () => { + const wrapper = mount(Affix, { + props: { offsetBottom: 20 }, + slots: { default: 'Bottom' }, + }) + expect(wrapper.find('span').text()).toBe('Bottom') + }) + + it('renders inner wrapper', () => { + const wrapper = mount(Affix, { slots: { default: 'x' } }) + expect(wrapper.find(ns.e('inner')).exists()).toBe(true) + }) + + it('does not apply fixed modifier when not stuck', () => { + const wrapper = mount(Affix, { + props: { offsetTop: 0 }, + slots: { default: 'X' }, + }) + // Without scrolling, jsdom's getBoundingClientRect returns 0 — element is technically at rect.top=0 + // We just verify the inner wrapper does NOT carry the fixed modifier here unconditionally. + // (this is a structural sanity check — exact value depends on rect) + expect(wrapper.find(ns.e('inner')).exists()).toBe(true) + }) + + it('accepts target as a function returning window', () => { + const wrapper = mount(Affix, { + props: { target: () => window }, + slots: { default: 'X' }, + }) + expect(wrapper.find(ns.b()).exists()).toBe(true) + }) + + it('cleans up scroll listeners on unmount', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener') + const wrapper = mount(Affix, { + slots: { default: 'X' }, + }) + wrapper.unmount() + // resize + scroll listeners should be removed + expect(removeSpy).toHaveBeenCalled() + removeSpy.mockRestore() + }) + + it('becomes fixed in top mode and emits change', async () => { + const wrapper = mount(Affix, { + props: { offsetTop: 10, zIndex: 99 }, + slots: { default: 'Fixed' }, + }) + const root = wrapper.find(ns.b()).element as HTMLElement + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ + top: 5, + bottom: 25, + height: 20, + width: 120, + left: 30, + right: 150, + x: 30, + y: 5, + toJSON: () => ({}), + }) + + window.dispatchEvent(new Event('scroll')) + await nextTick() + + const inner = wrapper.find(ns.e('inner')) + expect(inner.classes()).toContain(ns.em('inner', 'fixed').slice(1)) + expect(inner.attributes('style')).toContain('position: fixed') + expect(inner.attributes('style')).toContain('top: 10px') + expect(inner.attributes('style')).toContain('z-index: 99') + expect(wrapper.emitted('change')?.[0]).toEqual([true]) + }) + + it('becomes fixed in bottom mode', async () => { + const wrapper = mount(Affix, { + props: { offsetBottom: 10 }, + slots: { default: 'Bottom' }, + }) + const root = wrapper.find(ns.b()).element as HTMLElement + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ + top: 760, + bottom: 770, + height: 10, + width: 80, + left: 0, + right: 80, + x: 0, + y: 760, + toJSON: () => ({}), + }) + + window.dispatchEvent(new Event('scroll')) + await nextTick() + + expect(wrapper.find(ns.e('inner')).attributes('style')).toContain('bottom: 10px') + }) + + it('uses element target and updates when target prop changes', async () => { + const first = document.createElement('div') + const second = document.createElement('div') + document.body.append(first, second) + const firstAdd = vi.spyOn(first, 'addEventListener') + const firstRemove = vi.spyOn(first, 'removeEventListener') + const secondAdd = vi.spyOn(second, 'addEventListener') + + const wrapper = mount(Affix, { + props: { target: first }, + slots: { default: 'X' }, + }) + await nextTick() + expect(firstAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) + + await wrapper.setProps({ target: second }) + await nextTick() + + expect(firstRemove).toHaveBeenCalledWith('scroll', expect.any(Function)) + expect(secondAdd).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) + }) +}) 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..a0a3e1f5 --- /dev/null +++ b/packages/ccui/ui/alert/src/alert-types.ts @@ -0,0 +1,51 @@ +import type { ExtractPropTypes, PropType } from 'vue' +import type { CcSemanticClasses, CcSemanticStyles } from '../../shared/hooks/use-semantic' + +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, + }, + /** + * 语义化 DOM className 注入。可用 key:`root` / `icon` / `message` / `description`。 + */ + classNames: { + type: Object as PropType, + default: undefined, + }, + /** + * 语义化 DOM style 注入。可用 key 与 classNames 一致。 + */ + styles: { + type: Object as PropType, + default: undefined, + }, +} 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..106bec8d --- /dev/null +++ b/packages/ccui/ui/alert/src/alert.tsx @@ -0,0 +1,81 @@ +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..f4f2c8f9 --- /dev/null +++ b/packages/ccui/ui/alert/test/alert.test.ts @@ -0,0 +1,50 @@ +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) + }) + + describe('M-A2 classNames / styles 钩子', () => { + it('classNames.root 注入到根节点', () => { + const wrapper = mount(Alert, { + props: { message: 'm', classNames: { root: 'my-root' } }, + }) + expect(wrapper.find('.ccui-alert').classes()).toContain('my-root') + }) + + it('styles.root 注入到根节点 style', () => { + const wrapper = mount(Alert, { + props: { message: 'm', styles: { root: { color: 'red' } } }, + }) + expect(wrapper.find('.ccui-alert').attributes('style') || '').toContain('red') + }) + }) +}) diff --git a/packages/ccui/ui/anchor/index.ts b/packages/ccui/ui/anchor/index.ts new file mode 100644 index 00000000..3375f6b2 --- /dev/null +++ b/packages/ccui/ui/anchor/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import Anchor from './src/anchor' + +Anchor.install = function (app: App): void { + app.component(Anchor.name!, Anchor) +} + +export { Anchor } + +export default { + title: 'Anchor 锚点', + category: '导航', + status: '100%', + install(app: App): void { + app.component(Anchor.name!, Anchor) + }, +} diff --git a/packages/ccui/ui/anchor/src/anchor-types.ts b/packages/ccui/ui/anchor/src/anchor-types.ts new file mode 100644 index 00000000..2dcf87b9 --- /dev/null +++ b/packages/ccui/ui/anchor/src/anchor-types.ts @@ -0,0 +1,40 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export interface AnchorLink { + href: string + title?: string + children?: AnchorLink[] +} + +export const anchorProps = { + items: { + type: Array as PropType, + default: () => [], + }, + affix: { + type: Boolean, + default: true, + }, + bounds: { + type: Number, + default: 5, + }, + offsetTop: { + type: Number, + default: 0, + }, + scrollContainer: { + type: [String, Object] as PropType, + default: undefined, + }, + showInkInFixed: { + type: Boolean, + default: false, + }, + targetOffset: { + type: Number, + default: undefined, + }, +} as const + +export type AnchorProps = ExtractPropTypes diff --git a/packages/ccui/ui/anchor/src/anchor.scss b/packages/ccui/ui/anchor/src/anchor.scss new file mode 100644 index 00000000..99470377 --- /dev/null +++ b/packages/ccui/ui/anchor/src/anchor.scss @@ -0,0 +1,66 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-anchor { + position: relative; + padding-inline-start: 2px; + font-size: $ccui-font-size; + color: $ccui-color-text; + + &__ink { + position: absolute; + top: 0; + inset-inline-start: 0; + height: 100%; + width: 2px; + background-color: $ccui-color-border; + } + + &__ink-ball { + position: absolute; + inset-inline-start: 0; + width: 2px; + height: 16px; + background-color: $ccui-color-primary; + border-radius: 1px; + opacity: 0; + transition: + top $ccui-motion-duration-mid ease-in-out, + opacity $ccui-motion-duration-mid; + } + + &__link { + padding: 4px 0; + } + + &__link-title { + display: block; + padding: 4px 0 4px 16px; + color: $ccui-color-text; + text-decoration: none; + line-height: 1.5; + transition: color $ccui-motion-duration-mid; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + color: $ccui-color-primary; + } + + &--active { + color: $ccui-color-primary; + font-weight: 500; + } + } + + &__children { + .#{$cls-prefix}-anchor__link-title { + color: $ccui-color-text-secondary; + + &:hover, + &--active { + color: $ccui-color-primary; + } + } + } +} diff --git a/packages/ccui/ui/anchor/src/anchor.tsx b/packages/ccui/ui/anchor/src/anchor.tsx new file mode 100644 index 00000000..0bdf9ca8 --- /dev/null +++ b/packages/ccui/ui/anchor/src/anchor.tsx @@ -0,0 +1,204 @@ +import type { AnchorLink, AnchorProps } from './anchor-types' +import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { anchorProps } from './anchor-types' +import './anchor.scss' + +function getScrollContainer(target: AnchorProps['scrollContainer']): HTMLElement | Window { + if (!target) { + return window + } + if (typeof target === 'string') { + const el = document.querySelector(target) + return (el as HTMLElement) ?? window + } + return target +} + +function isWindow(target: HTMLElement | Window): target is Window { + return target === window +} + +function flatLinks(items: AnchorLink[]): AnchorLink[] { + const out: AnchorLink[] = [] + const walk = (list: AnchorLink[]) => { + list.forEach((it) => { + out.push(it) + if (it.children?.length) { + walk(it.children) + } + }) + } + walk(items) + return out +} + +function getElementOffsetTop(el: HTMLElement, container: HTMLElement | Window): number { + if (isWindow(container)) { + return el.getBoundingClientRect().top + } + if (typeof container.getBoundingClientRect !== 'function') { + return el.getBoundingClientRect().top + } + return el.getBoundingClientRect().top - container.getBoundingClientRect().top +} + +export default defineComponent({ + name: 'CAnchor', + props: anchorProps, + emits: ['change', 'click'], + setup(props: AnchorProps, { emit, slots }) { + const ns = useNamespace('anchor') + const activeLink = ref('') + const wrapperRef = ref() + const inkRef = ref() + const linkRefs = ref>(new Map()) + + const setLinkRef = (href: string) => (el: unknown) => { + const node = el as HTMLElement | null + if (node) { + linkRefs.value.set(href, node) + } else { + linkRefs.value.delete(href) + } + } + + const updateInk = () => { + if (!inkRef.value || !wrapperRef.value) { + return + } + const node = activeLink.value ? linkRefs.value.get(activeLink.value) : null + if (!node) { + inkRef.value.style.opacity = '0' + return + } + const wrapperTop = wrapperRef.value.getBoundingClientRect().top + const top = node.getBoundingClientRect().top - wrapperTop + inkRef.value.style.opacity = '1' + inkRef.value.style.top = `${top + node.offsetHeight / 2 - 2}px` + } + + const computeActive = () => { + const container = getScrollContainer(props.scrollContainer) + const all = flatLinks(props.items) + let current = '' + let minDelta = Number.POSITIVE_INFINITY + all.forEach((it) => { + const id = it.href.replace(/^#/, '') + const el = document.getElementById(id) + if (!el) { + return + } + const top = getElementOffsetTop(el, container) + const delta = top - props.bounds - props.offsetTop + if (delta <= 0 && Math.abs(delta) < minDelta) { + minDelta = Math.abs(delta) + current = it.href + } + }) + + // 滚动到底部时,最后一个区段可能因为容器无法继续上滚而始终越不过 bounds 线, + // 导致高亮停留在倒数第二个。此时直接激活最后一个有目标元素的锚点。 + const metrics = isWindow(container) + ? { + top: window.scrollY, + client: window.innerHeight, + scroll: document.documentElement.scrollHeight, + } + : { top: container.scrollTop, client: container.clientHeight, scroll: container.scrollHeight } + const scrollable = metrics.scroll - metrics.client > 4 + const atBottom = scrollable && Math.ceil(metrics.top + metrics.client) >= metrics.scroll - 2 + if (atBottom) { + for (let i = all.length - 1; i >= 0; i--) { + const id = all[i].href.replace(/^#/, '') + if (document.getElementById(id)) { + current = all[i].href + break + } + } + } + + if (current !== activeLink.value) { + activeLink.value = current + emit('change', current) + } + } + + const onScroll = () => { + computeActive() + nextTick(updateInk) + } + + let container: HTMLElement | Window | null = null + onMounted(() => { + container = getScrollContainer(props.scrollContainer) + container.addEventListener('scroll', onScroll, { passive: true }) + onScroll() + }) + onBeforeUnmount(() => { + container?.removeEventListener('scroll', onScroll) + }) + + watch(activeLink, () => { + nextTick(updateInk) + }) + watch( + () => props.items, + () => { + nextTick(onScroll) + }, + ) + + const onLinkClick = (e: MouseEvent, link: AnchorLink) => { + e.preventDefault() + emit('click', e, link) + const id = link.href.replace(/^#/, '') + const target = document.getElementById(id) + if (!target) { + return + } + const offset = props.targetOffset ?? props.offsetTop + const cont = getScrollContainer(props.scrollContainer) + if (isWindow(cont)) { + const top = target.getBoundingClientRect().top + window.scrollY - offset + window.scrollTo({ top, behavior: 'smooth' }) + } else if (typeof cont.scrollTo === 'function' && typeof cont.getBoundingClientRect === 'function') { + const top = target.getBoundingClientRect().top - cont.getBoundingClientRect().top + cont.scrollTop - offset + cont.scrollTo({ top, behavior: 'smooth' }) + } + activeLink.value = link.href + if (history.replaceState) { + history.replaceState(null, '', link.href) + } + } + + const renderLink = (link: AnchorLink, level = 0): JSX.Element => { + const active = activeLink.value === link.href + return ( +
+ onLinkClick(e, link)} + > + {link.title ?? link.href} + + {link.children?.length && ( +
{link.children.map((child) => renderLink(child, level + 1))}
+ )} +
+ ) + } + + return () => ( +
+
+ +
+ {props.items.length ? props.items.map((item) => renderLink(item)) : slots.default?.()} +
+ ) + }, +}) diff --git a/packages/ccui/ui/anchor/test/anchor.test.ts b/packages/ccui/ui/anchor/test/anchor.test.ts new file mode 100644 index 00000000..9a4509a9 --- /dev/null +++ b/packages/ccui/ui/anchor/test/anchor.test.ts @@ -0,0 +1,176 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Anchor } from '../index' + +const ns = useNamespace('anchor', true) + +afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('anchor', () => { + it('renders top-level links', () => { + const wrapper = mount(Anchor, { + props: { + items: [ + { href: '#one', title: 'One' }, + { href: '#two', title: 'Two' }, + ], + }, + }) + expect(wrapper.findAll(ns.e('link-title')).length).toBe(2) + }) + + it('renders nested children', () => { + const wrapper = mount(Anchor, { + props: { + items: [ + { + href: '#a', + title: 'A', + children: [{ href: '#a-1', title: 'A-1' }], + }, + ], + }, + }) + expect(wrapper.findAll(ns.e('link-title')).length).toBe(2) + expect(wrapper.find(ns.e('children')).exists()).toBe(true) + }) + + it('emits click on link click', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + document.body.innerHTML = '
' + const wrapper = mount(Anchor, { + props: { items: [{ href: '#x', title: 'X' }] }, + }) + await wrapper.find('a').trigger('click') + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')?.[0]?.[1]).toMatchObject({ href: '#x' }) + }) + + it('falls back to href when title is missing', () => { + const wrapper = mount(Anchor, { + props: { items: [{ href: '#raw' }] }, + }) + expect(wrapper.find(ns.e('link-title')).text()).toBe('#raw') + }) + + it('renders ink ball element', () => { + const wrapper = mount(Anchor, { + props: { items: [{ href: '#a', title: 'A' }] }, + }) + expect(wrapper.find(ns.e('ink-ball')).exists()).toBe(true) + expect(wrapper.find(ns.e('ink')).exists()).toBe(true) + }) + + it('applies affix modifier class when affix=true', () => { + const wrapper = mount(Anchor, { + props: { + affix: true, + items: [{ href: '#a', title: 'A' }], + }, + }) + expect(wrapper.find(ns.m('affix')).exists()).toBe(true) + }) + + it('uses default slot when items is empty', () => { + const wrapper = mount(Anchor, { + props: { items: [] }, + slots: { default: 'Custom' }, + }) + expect(wrapper.find('.custom-link').exists()).toBe(true) + }) + + it('emits change event when scroll lands on a section', async () => { + document.body.innerHTML = '
' + const wrapper = mount(Anchor, { + props: { + items: [ + { href: '#sec-a', title: 'A' }, + { href: '#sec-b', title: 'B' }, + ], + }, + }) + await nextTick() + // initial onScroll runs in onMounted; should emit change at least once with the first matched href + const events = wrapper.emitted('change') ?? [] + expect(events.length).toBeGreaterThan(0) + }) + + it('scrolls an element container using targetOffset and updates active link', async () => { + const container = document.createElement('div') + container.id = 'scroll-box' + container.scrollTop = 20 + const target = document.createElement('div') + target.id = 'inside' + document.body.append(container) + container.append(target) + const scrollTo = vi.fn() + Object.defineProperty(container, 'scrollTo', { value: scrollTo, configurable: true }) + vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + top: 10, + bottom: 310, + left: 0, + right: 100, + width: 100, + height: 300, + x: 0, + y: 10, + toJSON: () => ({}), + }) + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ + top: 80, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 20, + x: 0, + y: 80, + toJSON: () => ({}), + }) + + const wrapper = mount(Anchor, { + props: { + scrollContainer: '#scroll-box', + targetOffset: 12, + items: [{ href: '#inside', title: 'Inside' }], + }, + }) + await nextTick() + + await wrapper.find('a').trigger('click') + + expect(scrollTo).toHaveBeenCalledWith({ top: 78, behavior: 'smooth' }) + expect(wrapper.find(ns.em('link-title', 'active')).exists()).toBe(true) + }) + + it('updates active link when items prop changes', async () => { + const section = document.createElement('div') + section.id = 'new-section' + document.body.append(section) + vi.spyOn(section, 'getBoundingClientRect').mockReturnValue({ + top: 0, + bottom: 20, + left: 0, + right: 100, + width: 100, + height: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }) + const wrapper = mount(Anchor, { + props: { items: [] }, + }) + + await wrapper.setProps({ items: [{ href: '#new-section', title: 'New' }] }) + await nextTick() + await nextTick() + + expect(wrapper.emitted('change')?.at(-1)?.[0]).toBe('#new-section') + }) +}) diff --git a/packages/ccui/ui/auto-complete/index.ts b/packages/ccui/ui/auto-complete/index.ts new file mode 100644 index 00000000..4c19ddd4 --- /dev/null +++ b/packages/ccui/ui/auto-complete/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import AutoComplete from './src/auto-complete' + +AutoComplete.install = function (app: App): void { + app.component(AutoComplete.name!, AutoComplete) +} + +export { AutoComplete } + +export default { + title: 'AutoComplete 自动完成', + category: '数据录入', + status: '80%', + install(app: App): void { + app.component(AutoComplete.name!, AutoComplete) + }, +} diff --git a/packages/ccui/ui/auto-complete/src/auto-complete-types.ts b/packages/ccui/ui/auto-complete/src/auto-complete-types.ts new file mode 100644 index 00000000..718d2db0 --- /dev/null +++ b/packages/ccui/ui/auto-complete/src/auto-complete-types.ts @@ -0,0 +1,168 @@ +import type { ExtractPropTypes, PropType, VNode } from 'vue' +import type { CcSemanticClasses, CcSemanticStyles } from '../../shared/hooks/use-semantic' + +export type AutoCompleteSize = 'large' | 'default' | 'small' +export type AutoCompleteStatus = '' | 'error' | 'warning' | 'success' | 'validating' +export type AutoCompletePlacement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight' +export type GetPopupContainer = (triggerNode: HTMLElement | null) => HTMLElement | null + +export interface AutoCompleteOption { + value: string | number + label?: string + disabled?: boolean + // 业务自定义字段(透传,不参与匹配 / 显示) + [key: string]: unknown +} + +// options 既支持 string[](label === value)也支持 {value,label}[] +export type AutoCompleteSourceItem = string | number | AutoCompleteOption + +export type FilterOption = boolean | ((inputValue: string, option: AutoCompleteOption) => boolean) + +export const autoCompleteProps = { + modelValue: { + type: [String, Number] as PropType, + default: undefined, + }, + defaultValue: { + type: [String, Number] as PropType, + default: '', + }, + options: { + type: Array as PropType, + default: () => [], + }, + placeholder: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + allowClear: { + type: Boolean, + default: false, + }, + /** + * 自定义清除图标。接 string(Iconify name / CSS class)或 VNode; + * 同名 `clearIcon` slot 优先级最高。 + */ + clearIcon: { + type: [String, Object] as PropType, + default: undefined, + }, + size: { + type: String as PropType, + default: 'default', + }, + status: { + type: String as PropType, + default: '', + }, + // 过滤逻辑:true=默认包含匹配;false=不过滤(全部展示);function=自定义 + filterOption: { + type: [Boolean, Function] as PropType, + default: true, + }, + // 是否大小写敏感(默认 false) + caseSensitive: { + type: Boolean, + default: false, + }, + notFoundContent: { + type: String, + // 默认从 ConfigProvider.locale.AutoComplete.notFoundContent 取值;显式 prop 仍优先。 + default: '', + }, + placement: { + type: String as PropType, + default: 'bottomLeft', + }, + popupClassName: { + type: String, + default: '', + }, + popupAppendToBody: { + type: Boolean, + default: false, + }, + getPopupContainer: { + type: Function as PropType, + default: undefined, + }, + transitionName: { + type: String, + default: 'ccui-auto-complete-fade', + }, + // 浮层最大高度 + popupMaxHeight: { + type: Number, + default: 256, + }, + // 打开浮层时是否默认高亮第一项 + defaultActiveFirstOption: { + type: Boolean, + default: false, + }, + // 键盘高亮时是否把 active 项写回 input + backfill: { + type: Boolean, + default: false, + }, + // 搜索防抖延迟(毫秒),0 = 不防抖 + searchDebounce: { + type: Number, + default: 0, + }, + /** + * 录入组件统一 variant 形态。 + */ + variant: { + type: String as PropType, + default: 'outlined', + }, + /** + * 语义化 DOM className 注入。可用 key:`root` / `input` / `popup`。 + */ + classNames: { + type: Object as PropType, + default: undefined, + }, + /** + * 语义化 DOM style 注入。可用 key 与 classNames 一致。 + */ + styles: { + type: Object as PropType, + default: undefined, + }, +} as const + +export type AutoCompleteProps = ExtractPropTypes + +export type AutoCompleteVariant = 'outlined' | 'filled' | 'borderless' | 'underlined' + +// 内部规范化的 option 形态 +export interface NormalizedOption { + value: string | number + label: string + disabled: boolean + raw: AutoCompleteOption +} + +export function normalizeOption(item: AutoCompleteSourceItem): NormalizedOption { + if (typeof item === 'string' || typeof item === 'number') { + return { + value: item, + label: String(item), + disabled: false, + raw: { value: item, label: String(item) }, + } + } + return { + value: item.value, + label: item.label !== undefined ? String(item.label) : String(item.value), + disabled: !!item.disabled, + raw: item, + } +} diff --git a/packages/ccui/ui/auto-complete/src/auto-complete.scss b/packages/ccui/ui/auto-complete/src/auto-complete.scss new file mode 100644 index 00000000..e338e582 --- /dev/null +++ b/packages/ccui/ui/auto-complete/src/auto-complete.scss @@ -0,0 +1,147 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-auto-complete { + display: inline-block; + position: relative; + width: 100%; + + // M-A1:录入组件统一 variant 形态 —— 视觉边框在 __wrap + @include form-control-variants-on('.#{$cls-prefix}-auto-complete__wrap'); + + &__wrap { + display: inline-flex; + align-items: center; + gap: 4px; + width: 100%; + height: 32px; + padding: 0 11px; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: #fff; + box-sizing: border-box; + cursor: text; + transition: + border-color 0.2s, + box-shadow 0.2s; + + &:hover { + border-color: $ccui-color-primary-hover; + } + + &.is-open { + border-color: $ccui-color-primary; + box-shadow: 0 0 0 2px $ccui-control-outline; + } + + &.is-disabled { + background: #f5f5f5; + cursor: not-allowed; + + input { + cursor: not-allowed; + } + } + + &--size-small { + height: 24px; + } + &--size-large { + height: 40px; + } + + &--status-error { + border-color: #ff4d4f; + } + &--status-warning { + border-color: #faad14; + } + } + + &__input { + flex: 1; + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: rgba(0, 0, 0, 0.88); + font-size: 14px; + + &::placeholder { + color: rgba(0, 0, 0, 0.25); + } + + &:disabled { + color: rgba(0, 0, 0, 0.4); + } + } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.45); + font-size: 11px; + cursor: pointer; + border-radius: 50%; + transition: color 0.2s; + + &:hover { + color: rgba(0, 0, 0, 0.85); + } + } + + &__panel { + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 6px; + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12); + box-sizing: border-box; + overflow: auto; + z-index: 1050; + min-width: 100px; + } + + &__options { + list-style: none; + margin: 0; + padding: 4px 0; + } + + &__option { + padding: 5px 12px; + color: rgba(0, 0, 0, 0.88); + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + + &.is-active { + background: rgba(0, 0, 0, 0.04); + } + + &.is-disabled { + color: rgba(0, 0, 0, 0.25); + cursor: not-allowed; + background: transparent; + } + } + + &__empty { + padding: 12px; + color: rgba(0, 0, 0, 0.45); + font-size: 13px; + text-align: center; + } +} + +.ccui-auto-complete-fade-enter-active, +.ccui-auto-complete-fade-leave-active { + transition: opacity 0.15s ease; +} +.ccui-auto-complete-fade-enter-from, +.ccui-auto-complete-fade-leave-to { + opacity: 0; +} diff --git a/packages/ccui/ui/auto-complete/src/auto-complete.tsx b/packages/ccui/ui/auto-complete/src/auto-complete.tsx new file mode 100644 index 00000000..a885cb72 --- /dev/null +++ b/packages/ccui/ui/auto-complete/src/auto-complete.tsx @@ -0,0 +1,370 @@ +import type { Placement } from '@floating-ui/vue' +import type { CSSProperties, VNode } from 'vue' +import type { FormItemInjectedContext } from '../../form/src/form-types' +import type { AutoCompletePlacement, AutoCompleteProps, NormalizedOption } from './auto-complete-types' +import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' +import { + computed, + defineComponent, + getCurrentInstance, + inject, + onMounted, + onUnmounted, + ref, + shallowRef, + Teleport, + Transition, + watch, +} from 'vue' +import { useConfig } from '../../config-provider/src/config-provider' +import { formItemInjectionKey } from '../../form/src/form-types' +import { renderIconNode } from '../../shared/hooks/use-icon' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { autoCompleteProps, normalizeOption } from './auto-complete-types' +import './auto-complete.scss' + +const PLACEMENT_TO_FLOATING: Record = { + bottomLeft: 'bottom-start', + bottomRight: 'bottom-end', + topLeft: 'top-start', + topRight: 'top-end', +} + +export default defineComponent({ + name: 'CAutoComplete', + props: autoCompleteProps, + emits: ['update:modelValue', 'change', 'select', 'search', 'focus', 'blur', 'open-change'], + setup(props: AutoCompleteProps, { emit, slots }) { + const ns = useNamespace('auto-complete') + const cfg = useConfig() + const uid = getCurrentInstance()?.uid ?? 0 + const popupId = `ccui-auto-complete-popup-${uid}` + const optionId = (index: number) => `ccui-auto-complete-option-${uid}-${index}` + const notFoundLocal = computed( + () => props.notFoundContent || cfg.locale?.AutoComplete?.notFoundContent || '暂无数据', + ) + const rootRef = ref(null) + const popupRef = ref(null) + const inputRef = ref(null) + const open = shallowRef(false) + const innerValue = shallowRef(props.defaultValue ?? '') + const activeIndex = shallowRef(-1) + const formItem = inject(formItemInjectionKey, null) + + const isControlled = computed(() => props.modelValue !== undefined) + const currentValue = computed(() => { + const raw = isControlled.value ? props.modelValue : innerValue.value + return raw ?? '' + }) + const inputDisplay = computed(() => backfillDisplay.value ?? String(currentValue.value ?? '')) + + const normalized = computed(() => (props.options || []).map((item) => normalizeOption(item))) + + const filteredOptions = computed(() => { + const filterOption = props.filterOption + if (filterOption === false) return normalized.value + const keyword = inputDisplay.value + if (!keyword) return normalized.value + if (typeof filterOption === 'function') { + return normalized.value.filter((opt) => filterOption(keyword, opt.raw)) + } + const needle = props.caseSensitive ? keyword : keyword.toLowerCase() + return normalized.value.filter((opt) => { + const hay = props.caseSensitive ? opt.label : opt.label.toLowerCase() + return hay.includes(needle) + }) + }) + + const placement = computed(() => PLACEMENT_TO_FLOATING[props.placement]) + const popupContainer = computed(() => { + if (typeof document === 'undefined') return null + if (props.getPopupContainer) return props.getPopupContainer(rootRef.value) + if (props.popupAppendToBody) return document.body + return null + }) + const teleported = computed(() => popupContainer.value !== null) + + const { floatingStyles } = useFloating(rootRef, popupRef, { + placement: placement as never, + open, + whileElementsMounted: autoUpdate, + middleware: [offset(4), flip(), shift({ padding: 8 })], + strategy: computed(() => (teleported.value ? 'fixed' : 'absolute')) as never, + }) + + const validationStatus = computed(() => formItem?.validateStatus.value ?? '') + const mergedStatus = computed(() => props.status || validationStatus.value) + + function findFirstEnabledIndex(): number { + if (!props.defaultActiveFirstOption) return -1 + return filteredOptions.value.findIndex((o) => !o.disabled) + } + + function openPopup() { + if (props.disabled || open.value) return + open.value = true + activeIndex.value = findFirstEnabledIndex() + emit('open-change', true) + } + function closePopup() { + if (!open.value) return + open.value = false + backfillDisplay.value = null + emit('open-change', false) + } + + // backfill 临时显示值(不 emit,仅视觉) + const backfillDisplay = shallowRef(null) + + let debounceTimer: ReturnType | null = null + + function setValue(next: string | number) { + backfillDisplay.value = null + if (!isControlled.value) { + innerValue.value = next + } + emit('update:modelValue', next) + emit('change', next) + + if (props.searchDebounce > 0) { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + emit('search', String(next)) + }, props.searchDebounce) + } else { + emit('search', String(next)) + } + formItem?.validate('change') + } + + function selectOption(opt: NormalizedOption) { + if (opt.disabled) return + setValue(opt.value) + emit('select', opt.value, opt.raw) + closePopup() + // 选中后 input 仍保留焦点(mousedown.preventDefault 阻止 blur,键盘选中本就在 input 内), + // 但不再调用 inputRef.focus()——会触发 onFocus 重新 openPopup。 + } + + function onInput(e: Event) { + const target = e.target as HTMLInputElement + setValue(target.value) + if (!open.value) openPopup() + activeIndex.value = -1 + } + + function onFocus(e: FocusEvent) { + emit('focus', e) + if (!props.disabled) openPopup() + } + + function onBlur(e: FocusEvent) { + emit('blur', e) + formItem?.validate('blur') + } + + function onKeydown(e: KeyboardEvent) { + if (props.disabled) return + const list = filteredOptions.value + const enabled = list.filter((o) => !o.disabled) + function applyBackfill(idx: number) { + if (props.backfill && idx >= 0 && list[idx]) { + backfillDisplay.value = list[idx].label + } + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + if (!open.value) { + openPopup() + return + } + if (enabled.length === 0) return + const enabledIdx = enabled.findIndex((o) => o === list[activeIndex.value]) + const nextEnabled = enabled[(enabledIdx + 1) % enabled.length] + activeIndex.value = list.indexOf(nextEnabled) + applyBackfill(activeIndex.value) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (!open.value) { + openPopup() + return + } + if (enabled.length === 0) return + const enabledIdx = enabled.findIndex((o) => o === list[activeIndex.value]) + const prev = enabledIdx <= 0 ? enabled[enabled.length - 1] : enabled[enabledIdx - 1] + activeIndex.value = list.indexOf(prev) + applyBackfill(activeIndex.value) + } else if (e.key === 'Enter') { + if (open.value && activeIndex.value >= 0 && list[activeIndex.value]) { + e.preventDefault() + selectOption(list[activeIndex.value]) + } + } else if (e.key === 'Escape') { + if (open.value) { + e.preventDefault() + closePopup() + } + } + } + + function onClickOutside(e: MouseEvent) { + if (!open.value) return + const target = e.target as Node | null + if (!target) return + if (rootRef.value?.contains(target)) return + if (popupRef.value?.contains(target)) return + closePopup() + } + + function clear(e: MouseEvent) { + e.stopPropagation() + e.preventDefault() + setValue('') + } + + onMounted(() => { + document.addEventListener('mousedown', onClickOutside, true) + }) + onUnmounted(() => { + document.removeEventListener('mousedown', onClickOutside, true) + // 卸载时 flush debounce —— 否则延迟回调会在已销毁实例上 emit('search'), + // Vue 视为 no-op 不会崩,但会让"组件已离开页面但后端还多收一次搜索" + // 的浪费请求穿过去。 + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + }) + + // 当 options 变化或 keyword 变化时,如果当前 active index 越界,重置 + watch(filteredOptions, (list) => { + if (activeIndex.value >= list.length) activeIndex.value = -1 + }) + + const showClear = computed(() => props.allowClear && !props.disabled && inputDisplay.value !== '') + + function renderInput(): VNode { + const wrapClass = [ + ns.e('wrap'), + ns.em('size', props.size), + props.disabled ? ns.is('disabled') : '', + open.value ? ns.is('open') : '', + mergedStatus.value ? ns.em('wrap', `status-${mergedStatus.value}`) : '', + ] + + const inputNode = slots.trigger ? ( + slots.trigger({ + value: inputDisplay.value, + onInput, + onFocus, + onBlur, + onKeydown, + placeholder: props.placeholder, + disabled: props.disabled, + }) + ) : ( + = 0 ? optionId(activeIndex.value) : undefined} + onInput={onInput} + onFocus={onFocus} + onBlur={onBlur} + onKeydown={onKeydown} + /> + ) + + return ( +
+ {inputNode} + {showClear.value && ( + + {slots.clearIcon + ? slots.clearIcon() + : (renderIconNode(props.clearIcon) ?? renderIconNode('mdi:close-circle'))} + + )} +
+ ) + } + + function renderOption(opt: NormalizedOption, index: number): VNode { + const cls = [ + ns.e('option'), + index === activeIndex.value ? ns.is('active') : '', + opt.disabled ? ns.is('disabled') : '', + ] + return ( +
  • { + // 用 mousedown 阻止 input blur,避免 popup 在选中前关闭 + e.preventDefault() + selectOption(opt) + }} + onMouseenter={() => { + if (!opt.disabled) activeIndex.value = index + }} + > + {slots.option ? slots.option({ option: opt.raw, index }) : opt.label} +
  • + ) + } + + function renderPopup(): VNode | null { + if (!open.value) return null + const list = filteredOptions.value + const popupCls = [ns.e('panel'), props.popupClassName].filter(Boolean) as string[] + const panelStyle: CSSProperties = { + ...(floatingStyles.value as CSSProperties), + maxHeight: `${props.popupMaxHeight}px`, + } + return ( + + +
    + {list.length === 0 ? ( +
    {notFoundLocal.value}
    + ) : ( +
      {list.map((opt, i) => renderOption(opt, i))}
    + )} +
    +
    +
    + ) + } + + return () => ( +
    + {renderInput()} + {renderPopup()} +
    + ) + }, +}) diff --git a/packages/ccui/ui/auto-complete/test/auto-complete.test.ts b/packages/ccui/ui/auto-complete/test/auto-complete.test.ts new file mode 100644 index 00000000..333ddaf1 --- /dev/null +++ b/packages/ccui/ui/auto-complete/test/auto-complete.test.ts @@ -0,0 +1,486 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { defineComponent, h, nextTick, ref } from 'vue' +import { AutoComplete } from '../index' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Form, FormItem } from '../../form' +import { formItemInjectionKey } from '../../form/src/form-types' + +const ns = useNamespace('auto-complete', true) +const wrappers: VueWrapper[] = [] + +const SAMPLE: Array = [ + 'Apple', + 'Banana', + { value: 'Cherry' }, + { value: 'durian', label: 'Durian (smelly)' }, + { value: 'Elderberry', disabled: true }, +] + +function mountAC(props: Record = {}) { + const wrapper = mount(AutoComplete, { + props: { options: SAMPLE, ...props }, + attachTo: document.body, + }) + wrappers.push(wrapper) + return wrapper +} + +async function focus(wrapper: VueWrapper) { + await wrapper.find('input').trigger('focus') + await nextTick() +} + +afterEach(() => { + wrappers.splice(0).forEach((w) => w.unmount()) +}) + +describe('auto-complete rendering', () => { + it('renders an input with placeholder and no popup initially', () => { + const wrapper = mountAC({ placeholder: 'type to search' }) + expect(wrapper.find('input').attributes('placeholder')).toBe('type to search') + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('renders the current modelValue as input value', () => { + const wrapper = mountAC({ modelValue: 'Banana' }) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Banana') + }) + + it('disabled wrap class and prevents focus from opening popup', async () => { + const wrapper = mountAC({ disabled: true }) + expect(wrapper.find(ns.e('wrap')).classes()).toContain('is-disabled') + await focus(wrapper) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) +}) + +describe('auto-complete popup open / close', () => { + it('opens popup on focus and emits open-change(true)', async () => { + const wrapper = mountAC() + await focus(wrapper) + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + expect(wrapper.emitted('open-change')?.[0]).toEqual([true]) + }) + + it('closes on outside mousedown', async () => { + const wrapper = mountAC() + await focus(wrapper) + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('Escape key closes popup', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + await nextTick() + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) +}) + +describe('auto-complete option filtering', () => { + it('filters options by includes when filterOption=true (default)', async () => { + const wrapper = mountAC() + await focus(wrapper) + expect(wrapper.findAll(ns.e('option'))).toHaveLength(5) + await wrapper.find('input').setValue('an') + await nextTick() + // Banana matches; durian (label=Durian (smelly)) doesn't include 'an' + const labels = wrapper.findAll(ns.e('option')).map((n) => n.text()) + expect(labels).toContain('Banana') + expect(labels).not.toContain('Apple') + }) + + it('does not filter when filterOption=false', async () => { + const wrapper = mountAC({ filterOption: false }) + await focus(wrapper) + await wrapper.find('input').setValue('xx-no-match') + await nextTick() + expect(wrapper.findAll(ns.e('option'))).toHaveLength(5) + }) + + it('uses custom filterOption when given as function', async () => { + const wrapper = mountAC({ + filterOption: (input: string, option: { value: string }) => String(option.value).startsWith(input), + }) + await focus(wrapper) + await wrapper.find('input').setValue('A') + await nextTick() + const labels = wrapper.findAll(ns.e('option')).map((n) => n.text()) + expect(labels).toEqual(['Apple']) + }) + + it('caseSensitive toggles case-aware matching', async () => { + const wrapper = mountAC({ caseSensitive: true }) + await focus(wrapper) + await wrapper.find('input').setValue('apple') + await nextTick() + expect(wrapper.findAll(ns.e('option'))).toHaveLength(0) + expect(wrapper.find(ns.e('empty')).exists()).toBe(true) + }) + + it('renders notFoundContent when filtered list is empty', async () => { + const wrapper = mountAC({ notFoundContent: '没有匹配项' }) + await focus(wrapper) + await wrapper.find('input').setValue('zzzzzzz') + await nextTick() + expect(wrapper.find(ns.e('empty')).text()).toBe('没有匹配项') + }) +}) + +describe('auto-complete selection', () => { + it('clicking an option emits update:modelValue + select and closes popup', async () => { + const wrapper = mountAC() + await focus(wrapper) + const banana = wrapper.findAll(ns.e('option'))[1] + await banana.trigger('mousedown') + await nextTick() + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['Banana']) + expect(wrapper.emitted('select')?.[0][0]).toBe('Banana') + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('does not select disabled options', async () => { + const wrapper = mountAC() + await focus(wrapper) + const elderberry = wrapper.findAll(ns.e('option')).find((n) => n.text() === 'Elderberry')! + expect(elderberry.classes()).toContain('is-disabled') + await elderberry.trigger('mousedown') + await nextTick() + expect(wrapper.emitted('select')).toBeUndefined() + // popup 仍开 + expect(wrapper.find(ns.e('panel')).exists()).toBe(true) + }) + + it('select uses option.value not option.label when they differ', async () => { + const wrapper = mountAC() + await focus(wrapper) + const durian = wrapper.findAll(ns.e('option')).find((n) => n.text().includes('Durian'))! + await durian.trigger('mousedown') + await nextTick() + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['durian']) + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('durian') + }) +}) + +describe('auto-complete keyboard navigation', () => { + it('ArrowDown moves active to next enabled, skipping disabled', async () => { + const wrapper = mountAC() + await focus(wrapper) + // start: activeIndex=-1 + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await nextTick() + let active = wrapper.findAll(ns.e('option')).findIndex((n) => n.classes().includes('is-active')) + expect(active).toBe(0) // Apple + + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + active = wrapper.findAll(ns.e('option')).findIndex((n) => n.classes().includes('is-active')) + expect(active).toBe(1) // Banana + + // 跳到末尾后再 Down 应循环回到 0;而 Elderberry (idx 4) 是 disabled,应被跳过 + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + active = wrapper.findAll(ns.e('option')).findIndex((n) => n.classes().includes('is-active')) + expect(active).toBe(3) // Durian (last enabled) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + active = wrapper.findAll(ns.e('option')).findIndex((n) => n.classes().includes('is-active')) + expect(active).toBe(0) // wrap around + }) + + it('ArrowUp from start wraps to last enabled', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' }) + await nextTick() + const active = wrapper.findAll(ns.e('option')).findIndex((n) => n.classes().includes('is-active')) + expect(active).toBe(3) // Durian (last enabled) + }) + + it('Enter selects the active option', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await wrapper.find('input').trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['Banana']) + expect(wrapper.find(ns.e('panel')).exists()).toBe(false) + }) + + it('Enter with no active option does not select', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'Enter' }) + await nextTick() + expect(wrapper.emitted('select')).toBeUndefined() + }) +}) + +describe('auto-complete clear', () => { + it('renders clear button when allowClear and value is non-empty', async () => { + const wrapper = mountAC({ allowClear: true, defaultValue: 'Banana' }) + expect(wrapper.find(ns.e('clear')).exists()).toBe(true) + await wrapper.find(ns.e('clear')).trigger('mousedown') + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['']) + }) + + it('hides clear when value is empty even if allowClear=true', () => { + const wrapper = mountAC({ allowClear: true }) + expect(wrapper.find(ns.e('clear')).exists()).toBe(false) + }) + + it('hides clear when disabled', () => { + const wrapper = mountAC({ allowClear: true, defaultValue: 'Banana', disabled: true }) + expect(wrapper.find(ns.e('clear')).exists()).toBe(false) + }) +}) + +describe('auto-complete controlled / uncontrolled', () => { + it('uncontrolled: typing updates inner state and reflects in input', async () => { + const wrapper = mountAC() + await focus(wrapper) + await wrapper.find('input').setValue('hello') + await nextTick() + expect((wrapper.find('input').element as HTMLInputElement).value).toBe('hello') + expect(wrapper.emitted('update:modelValue')?.slice(-1)[0]).toEqual(['hello']) + }) + + it('controlled: typing emits update:modelValue but inner state does not write back', async () => { + const value = ref('init') + let lastEmit: string | null = null + const Host = defineComponent({ + setup() { + return () => + h(AutoComplete, { + modelValue: value.value, + options: SAMPLE, + 'onUpdate:modelValue': (v: string) => { + lastEmit = v + // 父级故意不写回 value,验证组件不会自己写 inner state + }, + }) + }, + }) + const wrapper = mount(Host, { attachTo: document.body }) + wrappers.push(wrapper) + await wrapper.find('input').trigger('focus') + await wrapper.find('input').setValue('changed') + await nextTick() + // emit 触发了,且父级拿到了新值;组件本身不会私自改 modelValue + expect(lastEmit).toBe('changed') + expect(value.value).toBe('init') + }) +}) + +describe('auto-complete form integration', () => { + it('inherits validate status from FormItem and triggers validate on change', async () => { + const value = ref('') + const Host = defineComponent({ + components: { Form, FormItem, AutoComplete }, + setup() { + return () => + h( + Form, + { + model: { brand: value.value }, + rules: { brand: [{ required: true, message: '请输入品牌', trigger: 'change' }] }, + }, + () => [ + h(FormItem, { name: 'brand', prop: 'brand', label: 'brand' }, () => + h(AutoComplete, { + modelValue: value.value, + options: SAMPLE, + 'onUpdate:modelValue': (v: string) => (value.value = v), + }), + ), + ], + ) + }, + }) + const wrapper = mount(Host, { attachTo: document.body }) + wrappers.push(wrapper) + await wrapper.find('input').trigger('focus') + await wrapper.find('input').setValue('Apple') + await nextTick() + expect(value.value).toBe('Apple') + }) +}) + +describe('auto-complete defaultActiveFirstOption', () => { + it('highlights first enabled option when popup opens', async () => { + const wrapper = mountAC({ defaultActiveFirstOption: true }) + await focus(wrapper) + const options = wrapper.findAll(ns.e('option')) + expect(options[0].classes()).toContain('is-active') + }) + + it('does not highlight first option by default', async () => { + const wrapper = mountAC() + await focus(wrapper) + const activeOptions = wrapper.findAll(`${ns.e('option')}.is-active`) + expect(activeOptions).toHaveLength(0) + }) +}) + +describe('auto-complete backfill', () => { + it('writes active option label to input on ArrowDown when backfill=true', async () => { + const wrapper = mountAC({ backfill: true }) + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await nextTick() + expect(wrapper.find('input').element.value).toBe('Apple') + }) + + it('clears backfill display on close', async () => { + const wrapper = mountAC({ backfill: true, defaultValue: '' }) + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await nextTick() + expect(wrapper.find('input').element.value).toBe('Apple') + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + await nextTick() + // backfill 清除后恢复为 defaultValue + expect(wrapper.find('input').element.value).toBe('') + }) +}) + +describe('auto-complete trigger slot', () => { + it('renders custom trigger via slot', async () => { + const wrapper = mount(AutoComplete, { + props: { options: SAMPLE }, + slots: { + trigger: (props: any) => + h('textarea', { + class: 'my-textarea', + value: props.value, + onInput: props.onInput, + onFocus: props.onFocus, + onKeydown: props.onKeydown, + }), + }, + attachTo: document.body, + }) + wrappers.push(wrapper) + expect(wrapper.find('.my-textarea').exists()).toBe(true) + expect(wrapper.find(ns.e('input')).exists()).toBe(false) + }) + + describe('variant', () => { + it('默认 variant 为 outlined', () => { + const wrapper = mountAC() + expect(wrapper.find(ns.m('variant-outlined')).exists()).toBe(true) + }) + + it('variant="filled"', () => { + const wrapper = mountAC({ variant: 'filled' }) + expect(wrapper.find(ns.m('variant-filled')).exists()).toBe(true) + }) + + it('variant="borderless"', () => { + const wrapper = mountAC({ variant: 'borderless' }) + expect(wrapper.find(ns.m('variant-borderless')).exists()).toBe(true) + }) + + it('variant="underlined"', () => { + const wrapper = mountAC({ variant: 'underlined' }) + expect(wrapper.find(ns.m('variant-underlined')).exists()).toBe(true) + }) + }) + + describe('status(校验状态 + Form 联动)', () => { + it('status="error" 加 __wrap--status-error 类', () => { + const wrapper = mountAC({ status: 'error' }) + expect(wrapper.find(ns.em('wrap', 'status-error')).exists()).toBe(true) + }) + + it('status="warning" 加 __wrap--status-warning 类', () => { + const wrapper = mountAC({ status: 'warning' }) + expect(wrapper.find(ns.em('wrap', 'status-warning')).exists()).toBe(true) + }) + + it('inherits validateStatus from injected FormItem context', () => { + const validateStatus = ref<'' | 'error'>('error') + const wrapper = mount(AutoComplete, { + props: { options: SAMPLE }, + attachTo: document.body, + global: { + provide: { + [formItemInjectionKey as symbol]: { + validateStatus, + isInsideForm: true, + validate: vi.fn(async () => true), + }, + }, + }, + }) + wrappers.push(wrapper) + expect(wrapper.find(ns.em('wrap', 'status-error')).exists()).toBe(true) + }) + }) + + describe('M-A4 clearIcon 钩子', () => { + it('clearIcon prop(CSS 类名)渲染 替代默认 ✕', () => { + const wrapper = mount(AutoComplete, { + props: { modelValue: 'hello', allowClear: true, clearIcon: 'my-clear' }, + global: { provide: { [formItemInjectionKey as any]: null } }, + }) + wrappers.push(wrapper) + expect(wrapper.find(`${ns.e('clear')} i.my-clear`).exists()).toBe(true) + expect(wrapper.find(ns.e('clear')).text()).not.toContain('✕') + }) + + it('clearIcon slot 优先级高于 prop', () => { + const wrapper = mount(AutoComplete, { + props: { modelValue: 'hello', allowClear: true, clearIcon: 'my-clear' }, + slots: { clearIcon: () => h('span', { class: 'slot-clear' }, 'X') }, + global: { provide: { [formItemInjectionKey as any]: null } }, + }) + wrappers.push(wrapper) + expect(wrapper.find('.slot-clear').exists()).toBe(true) + expect(wrapper.find('i.my-clear').exists()).toBe(false) + }) + }) +}) + +describe('auto-complete M-A2 classNames / styles 钩子', () => { + it('classNames.root 注入到根节点', () => { + const wrapper = mountAC({ classNames: { root: 'my-root' } }) + expect(wrapper.find(ns.b()).classes()).toContain('my-root') + }) + + it('styles.root 注入到根节点 style', () => { + const wrapper = mountAC({ styles: { root: { color: 'red' } } }) + expect(wrapper.find(ns.b()).attributes('style') || '').toContain('color: red') + }) +}) + +describe('XL-4 ARIA combobox / activedescendant', () => { + it('input 暴露 role="combobox" / aria-haspopup="listbox" / aria-controls / aria-expanded', async () => { + const wrapper = mountAC() + const input = wrapper.find('input') + expect(input.attributes('role')).toBe('combobox') + expect(input.attributes('aria-haspopup')).toBe('listbox') + expect(input.attributes('aria-autocomplete')).toBe('list') + expect(input.attributes('aria-controls')).toBeTruthy() + expect(input.attributes('aria-expanded')).toBe('false') + await focus(wrapper) + expect(wrapper.find('input').attributes('aria-expanded')).toBe('true') + }) + + it('ArrowDown 后 input.aria-activedescendant 指向高亮 option id', async () => { + const wrapper = mountAC({ defaultActiveFirstOption: true }) + await focus(wrapper) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await nextTick() + const desc = wrapper.find('input').attributes('aria-activedescendant') + expect(desc).toBeTruthy() + const activeOpt = wrapper.find(`#${desc}`) + expect(activeOpt.exists()).toBe(true) + expect(activeOpt.attributes('role')).toBe('option') + expect(activeOpt.attributes('aria-selected')).toBe('true') + }) +}) diff --git a/packages/ccui/ui/avatar/src/avatar-types.ts b/packages/ccui/ui/avatar/src/avatar-types.ts index 0117a59d..7f6ed9fe 100644 --- a/packages/ccui/ui/avatar/src/avatar-types.ts +++ b/packages/ccui/ui/avatar/src/avatar-types.ts @@ -1,7 +1,8 @@ import type { ExtractPropTypes, PropType } from 'vue' +import type { CcSemanticClasses, CcSemanticStyles } from '../../shared/hooks/use-semantic' export type FitType = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' -export type GenderType = 'male' | 'female' | string +export type GenderType = 'male' | 'female' | (string & {}) export const avatarProps = { name: { @@ -36,6 +37,20 @@ export const avatarProps = { type: String as PropType, default: 'cover', }, + /** + * 语义化 DOM className 注入。可用 key:`root` / `image` / `text`。 + */ + classNames: { + type: Object as PropType, + default: undefined, + }, + /** + * 语义化 DOM style 注入。可用 key 与 classNames 一致。 + */ + styles: { + type: Object as PropType, + default: undefined, + }, } as const export type AvatarProps = ExtractPropTypes 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/avatar/src/avatar.tsx b/packages/ccui/ui/avatar/src/avatar.tsx index 31265a15..48162309 100644 --- a/packages/ccui/ui/avatar/src/avatar.tsx +++ b/packages/ccui/ui/avatar/src/avatar.tsx @@ -13,8 +13,7 @@ export default defineComponent({ props: avatarProps, emits: [], setup(props: AvatarProps) { - const { name, width, height, customText, gender, imgSrc, isRound, fit } - = toRefs(props) + const { name, width, height, customText, gender, imgSrc, isRound, fit } = toRefs(props) // 头像图片加载是否有错误 const isErrorImg = ref(false) @@ -28,11 +27,7 @@ export default defineComponent({ const minNum = ref(Math.min(width.value, height.value)) // 获取展示名称 - nameDisplay.value = useGetDisplayName( - name.value, - customText.value, - minNum.value, - ) + nameDisplay.value = useGetDisplayName(name.value, customText.value, minNum.value) // 计算展示文本的字体大小 fontSize.value = minNum.value / 4 + 3 @@ -41,10 +36,7 @@ export default defineComponent({ isNobody.value = !!name.value && name.value === '' // 计算背景颜色code - BgColorCode.value = useGetBackgroundColor( - gender.value, - nameDisplay.value.substring(0, 1), - ) + BgColorCode.value = useGetBackgroundColor(gender.value, nameDisplay.value.substring(0, 1)) } watch( @@ -64,13 +56,19 @@ export default defineComponent({ src={imgSrc.value} alt="" onError={showErrorAvatar} - style={{ - width: `${width.value}px`, - height: `${height.value}px`, - verticalAlign: 'middle', - objectFit: fit.value, - borderRadius: isRound.value ? '100%' : '0', - }} + class={[props.classNames?.image]} + style={ + [ + { + width: `${width.value}px`, + height: `${height.value}px`, + verticalAlign: 'middle', + objectFit: fit.value, + borderRadius: isRound.value ? '100%' : '0', + }, + props.styles?.image, + ] as any + } /> ) @@ -78,10 +76,7 @@ export default defineComponent({ const styleNs = ns.e('style') const imgSrcErrElement = ( - + ) @@ -104,24 +99,26 @@ export default defineComponent({ const nameElement = ( {nameDisplay.value} ) const noNameElement = ( - + ) @@ -133,15 +130,14 @@ export default defineComponent({ if (!isNobody.value && nameDisplay.value?.length !== 0) { return nameElement - } - else { + } else { return noNameElement } } return () => { return ( -
    +
    {hasImgElement()} {hasNameElement()}
    diff --git a/packages/ccui/ui/avatar/src/components/icon-body.tsx b/packages/ccui/ui/avatar/src/components/icon-body.tsx index f991b6c5..4052379a 100644 --- a/packages/ccui/ui/avatar/src/components/icon-body.tsx +++ b/packages/ccui/ui/avatar/src/components/icon-body.tsx @@ -1,4 +1,4 @@ -export function IconBody(props: { width: number, height: number }) { +export function IconBody(props: { width: number; height: number }) { const { width, height } = props return ( - + - + > ) diff --git a/packages/ccui/ui/avatar/src/components/icon-img-error.tsx b/packages/ccui/ui/avatar/src/components/icon-img-error.tsx index 56b66163..5434293c 100644 --- a/packages/ccui/ui/avatar/src/components/icon-img-error.tsx +++ b/packages/ccui/ui/avatar/src/components/icon-img-error.tsx @@ -1,4 +1,4 @@ -export function IconImgError(props: { width: number, height: number }) { +export function IconImgError(props: { width: number; height: number }) { const { width, height } = props return ( - + > - + > - + > - + > ) } diff --git a/packages/ccui/ui/avatar/src/components/icon-nobody.tsx b/packages/ccui/ui/avatar/src/components/icon-nobody.tsx index b74126af..9570aef3 100644 --- a/packages/ccui/ui/avatar/src/components/icon-nobody.tsx +++ b/packages/ccui/ui/avatar/src/components/icon-nobody.tsx @@ -1,4 +1,4 @@ -export function IconNobody(props: { width: number, height: number }) { +export function IconNobody(props: { width: number; height: number }) { const { width, height } = props return ( - + > - + > ) diff --git a/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts b/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts index 033b3a04..784818c2 100644 --- a/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts +++ b/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts @@ -1,23 +1,17 @@ import { ref } from 'vue' -export default function getBackgroundColor( - gender: string, - char: string, -): number { +export default function getBackgroundColor(gender: string, char: string): number { const code = ref(1) // 性别存在 直接使用性别 if (gender) { if (gender.toLowerCase() === 'male') { code.value = 1 - } - else if (gender.toLowerCase() === 'female') { + } else if (gender.toLowerCase() === 'female') { code.value = 0 - } - else { + } else { throw new Error('gender must be "Male" or "Female"') } - } - else { + } else { const unicode = char.charCodeAt(0) code.value = unicode % 2 } diff --git a/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts b/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts index 52d46ff4..78321999 100644 --- a/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts +++ b/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts @@ -10,12 +10,8 @@ function nameFormatting(name: string) { if (/[_ -]/.test(name)) { const str_before = name.split(/_|-|\s+/)[0] const str_after = name.split(/_|-|\s+/)[1] - return ( - str_before.substring(0, 1).toUpperCase() - + str_after.substring(0, 1).toUpperCase() - ) - } - else { + return str_before.substring(0, 1).toUpperCase() + str_after.substring(0, 1).toUpperCase() + } else { // 一个英文名的情况显示前两个字母 return name.substring(0, 2).toUpperCase() } @@ -25,11 +21,7 @@ function nameFormatting(name: string) { return name.substring(0, 2) } -export default function useGetDisplayName( - name: string, - customText: string, - width: number, -): string { +export default function useGetDisplayName(name: string, customText: string, width: number): string { let nameDisplay = '' if (customText) { return customText.substring(0, 1) diff --git a/packages/ccui/ui/avatar/test/avatar.test.ts b/packages/ccui/ui/avatar/test/avatar.test.ts index 606a6277..77e4fa3d 100644 --- a/packages/ccui/ui/avatar/test/avatar.test.ts +++ b/packages/ccui/ui/avatar/test/avatar.test.ts @@ -1,5 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { useNamespace } from '../../shared/hooks/use-namespace' import { Avatar } from '../index' @@ -76,7 +76,7 @@ describe('avatar', () => { // Simulate image error const img = wrapper.find('img') - img.trigger('error') + await img.trigger('error') await wrapper.vm.$nextTick() expect(wrapper.findComponent({ name: 'IconImgError' }).exists()).toBe(true) @@ -166,4 +166,22 @@ describe('avatar', () => { wrapper.unmount() }) + + describe('M-A2 classNames / styles 钩子', () => { + it('classNames.root 注入到根节点', () => { + const wrapper = mount(Avatar, { + props: { name: 'A', classNames: { root: 'my-root' } }, + }) + expect(wrapper.find('.ccui-avatar').classes()).toContain('my-root') + wrapper.unmount() + }) + + it('styles.root 注入到根节点 style', () => { + const wrapper = mount(Avatar, { + props: { name: 'A', styles: { root: { color: 'red' } } }, + }) + expect(wrapper.find('.ccui-avatar').attributes('style') || '').toContain('red') + wrapper.unmount() + }) + }) }) diff --git a/packages/ccui/ui/badge-ribbon/index.ts b/packages/ccui/ui/badge-ribbon/index.ts new file mode 100644 index 00000000..06cf9ef9 --- /dev/null +++ b/packages/ccui/ui/badge-ribbon/index.ts @@ -0,0 +1,21 @@ +import type { App } from 'vue' +import BadgeRibbon from './src/badge-ribbon' + +BadgeRibbon.install = function (app: App): void { + app.component(BadgeRibbon.name!, BadgeRibbon) +} + +export { BadgeRibbon } + +export type { BadgeRibbonPlacement, BadgeRibbonProps } from './src/badge-ribbon-types' + +export { isRibbonPresetColor } from './src/badge-ribbon-types' + +export default { + title: 'BadgeRibbon 缎带徽标', + category: '数据展示', + status: '90%', + install(app: App): void { + app.component(BadgeRibbon.name!, BadgeRibbon) + }, +} diff --git a/packages/ccui/ui/badge-ribbon/src/badge-ribbon-types.ts b/packages/ccui/ui/badge-ribbon/src/badge-ribbon-types.ts new file mode 100644 index 00000000..694ecfcf --- /dev/null +++ b/packages/ccui/ui/badge-ribbon/src/badge-ribbon-types.ts @@ -0,0 +1,55 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export type BadgeRibbonPlacement = 'start' | 'end' + +/** + * 与 Tag / Badge 对齐的预设色名(小写)。 + */ +const PRESET_COLORS = new Set([ + 'pink', + 'magenta', + 'red', + 'volcano', + 'orange', + 'yellow', + 'gold', + 'cyan', + 'lime', + 'green', + 'blue', + 'geekblue', + 'purple', +]) + +export function isRibbonPresetColor(color: string): boolean { + return PRESET_COLORS.has(color) +} + +export const badgeRibbonProps = { + /** + * 缎带文字。slot 同名 `text` 优先级更高。 + */ + text: { + type: String, + default: '', + }, + /** + * 缎带颜色: + * + * - 预设色名(`'pink' | 'red' | ...`):走 SCSS preset class + * - CSS 字符串(`'#1677ff'` / `'rgb(...)'`):走 inline background + */ + color: { + type: String, + default: '', + }, + /** + * 缎带位置。`'end'`(默认)右上;`'start'` 左上。 + */ + placement: { + type: String as PropType, + default: 'end', + }, +} as const + +export type BadgeRibbonProps = ExtractPropTypes diff --git a/packages/ccui/ui/badge-ribbon/src/badge-ribbon.scss b/packages/ccui/ui/badge-ribbon/src/badge-ribbon.scss new file mode 100644 index 00000000..a60c5078 --- /dev/null +++ b/packages/ccui/ui/badge-ribbon/src/badge-ribbon.scss @@ -0,0 +1,88 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-badge-ribbon { + // 缎带主体 + position: absolute; + top: 8px; + height: 22px; + padding: 0 8px; + color: $ccui-color-white; + line-height: 22px; + font-size: $ccui-font-size-sm; + white-space: nowrap; + background-color: $ccui-color-primary; + border-radius: $ccui-border-radius-sm; + user-select: none; + z-index: 1; + + &__wrapper { + position: relative; + display: inline-block; + } + + &__text { + color: inherit; + } + + // 三角折角 + &__corner { + position: absolute; + top: 100%; + width: 8px; + height: 8px; + color: $ccui-color-primary; // 默认主色,custom 用 inline style 覆盖 + border: 4px solid; + transform: scaleY(0.75); + transform-origin: top; + } + + // 右上(默认) + &--end { + right: -8px; + border-bottom-right-radius: 0; + + .#{$cls-prefix}-badge-ribbon__corner { + right: 0; + border-color: currentColor transparent transparent currentColor; + } + } + + // 左上 + &--start { + left: -8px; + border-bottom-left-radius: 0; + + .#{$cls-prefix}-badge-ribbon__corner { + left: 0; + border-color: currentColor currentColor transparent transparent; + } + } + + // 预设色:背景与折角同色(color tokens 在 packages/theme/theme.scss) + @each $name, + $token + in ( + 'pink': $ccui-pink-6, + 'magenta': $ccui-magenta-6, + 'red': $ccui-red-6, + 'volcano': $ccui-volcano-6, + 'orange': $ccui-orange-6, + 'yellow': $ccui-yellow-6, + 'gold': $ccui-gold-6, + 'cyan': $ccui-cyan-6, + 'lime': $ccui-lime-6, + 'green': $ccui-green-6, + 'blue': $ccui-blue-6, + 'geekblue': $ccui-geekblue-6, + 'purple': $ccui-purple-6 + ) + { + &--color-#{$name} { + background-color: $token; + + .#{$cls-prefix}-badge-ribbon__corner { + color: $token; + } + } + } +} diff --git a/packages/ccui/ui/badge-ribbon/src/badge-ribbon.tsx b/packages/ccui/ui/badge-ribbon/src/badge-ribbon.tsx new file mode 100644 index 00000000..af4bd927 --- /dev/null +++ b/packages/ccui/ui/badge-ribbon/src/badge-ribbon.tsx @@ -0,0 +1,41 @@ +import type { BadgeRibbonProps } from './badge-ribbon-types' +import { computed, defineComponent, h } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { badgeRibbonProps, isRibbonPresetColor } from './badge-ribbon-types' +import './badge-ribbon.scss' + +export default defineComponent({ + name: 'CBadgeRibbon', + props: badgeRibbonProps, + setup(props: BadgeRibbonProps, { slots }) { + const ns = useNamespace('badge-ribbon') + + const isPreset = computed(() => !!props.color && isRibbonPresetColor(props.color)) + const customColor = computed(() => (!!props.color && !isPreset.value ? props.color : '')) + + const ribbonCls = computed(() => ({ + [ns.b()]: true, + [ns.m(props.placement)]: true, + [ns.m(`color-${props.color}`)]: isPreset.value, + })) + + return () => + h('div', { class: ns.e('wrapper') }, [ + slots.default?.(), + h( + 'div', + { + class: ribbonCls.value, + style: customColor.value ? { backgroundColor: customColor.value } : undefined, + }, + [ + h('span', { class: ns.e('text') }, slots.text ? slots.text() : props.text), + h('div', { + class: ns.e('corner'), + style: customColor.value ? { color: customColor.value } : undefined, + }), + ], + ), + ]) + }, +}) diff --git a/packages/ccui/ui/badge-ribbon/test/badge-ribbon.test.ts b/packages/ccui/ui/badge-ribbon/test/badge-ribbon.test.ts new file mode 100644 index 00000000..1c5d5aec --- /dev/null +++ b/packages/ccui/ui/badge-ribbon/test/badge-ribbon.test.ts @@ -0,0 +1,112 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vite-plus/test' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { BadgeRibbon } from '../index' + +const ns = useNamespace('badge-ribbon', true) +const cls = useNamespace('badge-ribbon') + +describe('badge-ribbon', () => { + describe('基本渲染', () => { + it('挂载基础 wrapper + ribbon DOM', () => { + const wrapper = mount(BadgeRibbon, { + props: { text: '新' }, + slots: { default: '
    内容
    ' }, + }) + expect(wrapper.find(ns.e('wrapper')).exists()).toBe(true) + expect(wrapper.find(ns.b()).exists()).toBe(true) + expect(wrapper.find('.box').exists()).toBe(true) + }) + + it('text prop 显示为缎带文字', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'HOT' } }) + expect(wrapper.find(ns.e('text')).text()).toBe('HOT') + }) + + it('text slot 优先于 text prop', () => { + const wrapper = mount(BadgeRibbon, { + props: { text: 'fallback' }, + slots: { text: '自定义' }, + }) + expect(wrapper.find(ns.e('text')).find('.ant-text').exists()).toBe(true) + expect(wrapper.find(ns.e('text')).text()).toBe('自定义') + }) + + it('default slot 渲染', () => { + const wrapper = mount(BadgeRibbon, { + slots: { default: '
    被包裹
    ' }, + }) + expect(wrapper.find('.content').exists()).toBe(true) + }) + + it('corner 三角节点存在', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X' } }) + expect(wrapper.find(ns.e('corner')).exists()).toBe(true) + }) + }) + + describe('placement', () => { + it('默认 placement=end 加 --end modifier', () => { + const wrapper = mount(BadgeRibbon, { props: { text: '默认' } }) + expect(wrapper.find(ns.b()).classes()).toContain(cls.m('end')) + }) + + it('placement=start 加 --start modifier', () => { + const wrapper = mount(BadgeRibbon, { props: { text: '左', placement: 'start' } }) + expect(wrapper.find(ns.b()).classes()).toContain(cls.m('start')) + }) + }) + + describe('color', () => { + it('color 不传时无 --color-* 也无 inline style', () => { + const wrapper = mount(BadgeRibbon, { props: { text: '默认' } }) + const ribbon = wrapper.find(ns.b()) + const hasPreset = ribbon.classes().some((c) => c.startsWith(cls.m('color-'))) + expect(hasPreset).toBe(false) + expect(ribbon.attributes('style')).toBeUndefined() + }) + + it('预设色 red 加 --color-red modifier', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: 'red' } }) + expect(wrapper.find(ns.b()).classes()).toContain(cls.m('color-red')) + // 预设色不挂 inline style + expect(wrapper.find(ns.b()).attributes('style')).toBeUndefined() + }) + + it('预设色 blue 加 --color-blue modifier', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: 'blue' } }) + expect(wrapper.find(ns.b()).classes()).toContain(cls.m('color-blue')) + }) + + it('自定义色(#hex)写入 inline background-color', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: '#ff5500' } }) + const ribbon = wrapper.find(ns.b()) + const style = ribbon.attributes('style') ?? '' + expect(style).toContain('background-color') + expect(style).toContain('rgb(255, 85, 0)') + }) + + it('自定义色同时写入 corner inline color', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: '#ff5500' } }) + const corner = wrapper.find(ns.e('corner')) + const style = corner.attributes('style') ?? '' + expect(style).toContain('color') + }) + + it('自定义色不挂 --color-* modifier', () => { + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: '#ff5500' } }) + const hasPreset = wrapper + .find(ns.b()) + .classes() + .some((c) => c.startsWith(cls.m('color-'))) + expect(hasPreset).toBe(false) + }) + + it('未知字符串(非预设、非看似颜色)作为 inline color 透传', () => { + // 非预设名都当作 CSS color 字面量 + const wrapper = mount(BadgeRibbon, { props: { text: 'X', color: 'hotpink' } }) + const style = wrapper.find(ns.b()).attributes('style') ?? '' + expect(style).toContain('background-color') + }) + }) +}) 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..971828a3 --- /dev/null +++ b/packages/ccui/ui/badge/src/badge-types.ts @@ -0,0 +1,55 @@ +import type { ExtractPropTypes, PropType } from 'vue' +import type { CcSemanticClasses, CcSemanticStyles } from '../../shared/hooks/use-semantic' + +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 unknown as PropType<[number, number]>, + default: undefined, + }, + /** + * 语义化 DOM className 注入。可用 key:`root` / `dot` / `count`。 + */ + classNames: { + type: Object as PropType, + default: undefined, + }, + /** + * 语义化 DOM style 注入。可用 key 与 classNames 一致。 + */ + styles: { + type: Object as PropType, + 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..426a9f74 --- /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: 2; + 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..9569597e --- /dev/null +++ b/packages/ccui/ui/badge/src/badge.tsx @@ -0,0 +1,113 @@ +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..82355ce9 --- /dev/null +++ b/packages/ccui/ui/badge/test/badge.test.ts @@ -0,0 +1,88 @@ +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) + }) + + it('renders standalone dot and custom color', () => { + const wrapper = mount(Badge, { props: { dot: true, color: '#f00' } }) + expect(wrapper.find(ns.m('dot-standalone')).exists()).toBe(true) + expect(wrapper.find(ns.b()).attributes('style')).toContain('background-color: rgb(255, 0, 0)') + }) + + it('renders status with custom color and no text', () => { + const wrapper = mount(Badge, { props: { color: '#00ff00' } }) + expect(wrapper.find(ns.e('status-dot')).exists()).toBe(true) + expect(wrapper.find(ns.e('status-text')).exists()).toBe(false) + }) + + it('renders string count and offset in wrapped mode', () => { + const wrapper = mount(Badge, { + props: { count: 'new', offset: [4, 8] }, + slots: { default: '' }, + }) + const sup = wrapper.find(ns.e('sup')) + + expect(sup.text()).toBe('new') + expect(sup.attributes('style')).toContain('translate') + }) + + it('hides empty count in wrapped mode', () => { + const wrapper = mount(Badge, { + props: { count: '' }, + slots: { default: '' }, + }) + + expect(wrapper.find(ns.e('sup')).exists()).toBe(false) + expect(wrapper.text()).toBe('Inbox') + }) + + describe('M-A2 classNames / styles 钩子', () => { + it('classNames.root 注入到根节点', () => { + const wrapper = mount(Badge, { + props: { count: 5, classNames: { root: 'my-root' } }, + }) + expect(wrapper.find('.ccui-badge').classes()).toContain('my-root') + }) + + it('styles.root 注入到根节点 style', () => { + const wrapper = mount(Badge, { + props: { count: 5, styles: { root: { color: 'red' } } }, + }) + expect(wrapper.find('.ccui-badge').attributes('style') || '').toContain('red') + }) + }) +}) diff --git a/packages/ccui/ui/border-beam/index.ts b/packages/ccui/ui/border-beam/index.ts new file mode 100644 index 00000000..89934696 --- /dev/null +++ b/packages/ccui/ui/border-beam/index.ts @@ -0,0 +1,19 @@ +import type { App } from 'vue' +import BorderBeam from './src/border-beam' + +BorderBeam.install = function (app: App): void { + app.component(BorderBeam.name!, BorderBeam) +} + +export { BorderBeam } +export { borderBeamPresetKeys, borderBeamPresets } from './src/border-beam-presets' +export type { BorderBeamPreset } from './src/border-beam-presets' + +export default { + title: 'BorderBeam 边框流光', + category: '通用', + status: '100%', + install(app: App): void { + app.component(BorderBeam.name!, BorderBeam) + }, +} diff --git a/packages/ccui/ui/border-beam/src/border-beam-presets.ts b/packages/ccui/ui/border-beam/src/border-beam-presets.ts new file mode 100644 index 00000000..bc8b4341 --- /dev/null +++ b/packages/ccui/ui/border-beam/src/border-beam-presets.ts @@ -0,0 +1,78 @@ +import type { BorderBeamColorStop } from './border-beam-types' + +export interface BorderBeamPreset { + /** 预设标识 */ + key: string + /** 预设名称 */ + name: string + /** 渐变停靠点 */ + color: BorderBeamColorStop[] +} + +/** + * 内置 6 套流光配色预设,可直接传给 `color`: + * + * ```ts + * import { borderBeamPresets } from '@vaebe/ccui' + * + * ``` + */ +export const borderBeamPresets = { + ocean: { + key: 'ocean', + name: 'Ocean', + color: [ + { color: '#1677ff', percent: 0 }, + { color: '#36cfc9', percent: 52 }, + { color: '#95de64', percent: 100 }, + ], + }, + sunset: { + key: 'sunset', + name: 'Sunset', + color: [ + { color: '#ff7a45', percent: 0 }, + { color: '#ff4d4f', percent: 49 }, + { color: '#ff85c0', percent: 100 }, + ], + }, + aurora: { + key: 'aurora', + name: 'Aurora', + color: [ + { color: '#7c3aed', percent: 0 }, + { color: '#06b6d4', percent: 57 }, + { color: '#67e8f9', percent: 100 }, + ], + }, + forest: { + key: 'forest', + name: 'Forest', + color: [ + { color: '#22c55e', percent: 0 }, + { color: '#a3e635', percent: 54 }, + { color: '#facc15', percent: 100 }, + ], + }, + ember: { + key: 'ember', + name: 'Ember', + color: [ + { color: '#fa541c', percent: 0 }, + { color: '#ff7875', percent: 46 }, + { color: '#ffd666', percent: 100 }, + ], + }, + nebula: { + key: 'nebula', + name: 'Nebula', + color: [ + { color: '#2f54eb', percent: 0 }, + { color: '#722ed1', percent: 44 }, + { color: '#ff85c0', percent: 100 }, + ], + }, +} satisfies Record + +/** 预设 key 列表,顺序与内置定义一致 */ +export const borderBeamPresetKeys = Object.keys(borderBeamPresets) as Array diff --git a/packages/ccui/ui/border-beam/src/border-beam-types.ts b/packages/ccui/ui/border-beam/src/border-beam-types.ts new file mode 100644 index 00000000..57dc4860 --- /dev/null +++ b/packages/ccui/ui/border-beam/src/border-beam-types.ts @@ -0,0 +1,43 @@ +import type { ExtractPropTypes, PropType } from 'vue' + +export interface BorderBeamColorStop { + /** 颜色值,支持任意 CSS 颜色 */ + color: string + /** 渐变停靠位置,取值 0 ~ 100 */ + percent: number +} + +export type BorderBeamColor = string | BorderBeamColorStop[] + +/** 用户传入的 0~100 停靠点会被映射进光带前 70%,预留尾段做淡出 */ +export const MAX_BEAM_COLOR_STOP_PERCENT = 70 + +export const borderBeamProps = { + /** 流光颜色,单色字符串或渐变停靠点数组;不传时使用主题主色渐变 */ + color: { + type: [String, Array] as PropType, + default: undefined, + }, + /** 流光层相对容器边缘的外扩距离(number 视为 px) */ + outset: { + type: [Number, String] as PropType, + default: 0, + }, + /** 边框 / 光带粗细(px) */ + borderWidth: { + type: Number, + default: 1, + }, + /** 容器圆角(px),与被包裹内容保持一致即可严丝合缝 */ + borderRadius: { + type: Number, + default: 8, + }, + /** 跑完一圈的时长(秒) */ + duration: { + type: Number, + default: 6, + }, +} as const + +export type BorderBeamProps = ExtractPropTypes diff --git a/packages/ccui/ui/border-beam/src/border-beam.scss b/packages/ccui/ui/border-beam/src/border-beam.scss new file mode 100644 index 00000000..f57dcc7c --- /dev/null +++ b/packages/ccui/ui/border-beam/src/border-beam.scss @@ -0,0 +1,71 @@ +@use '../../style-var/index.scss' as *; + +// 对标 Ant Design border-beam 的实现:mask 取边框环 + offset-path 矩形路径(固定 round 100px, +// 大圆角让过弯旋转平滑无卡顿)+ offset-anchor 90% 50% 形成拖尾彗星,offset-distance 0→100% 循环。 +.#{$cls-prefix}-border-beam { + position: relative; + border-radius: var(--ccui-bb-radius); + + &__effect { + display: none; + position: absolute; + inset: calc(-1 * var(--ccui-bb-outset)); + z-index: 1; + padding: var(--ccui-bb-border-width); // 边框 / 光带粗细 + overflow: hidden; + pointer-events: none; + border-radius: var(--ccui-bb-radius); + + // 只显示 padding 环(边框):content-box 实心 + 整体实心,取异或得到边框环 + @supports ((mask-composite: exclude) or (-webkit-mask-composite: xor)) { + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + + @supports (offset-path: rect(0 auto auto 0 round 1px)) { + display: block; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100px; + aspect-ratio: 1 / 1; + opacity: 0.95; + background-image: var( + --ccui-bb-beam-gradient, + linear-gradient(to left, #{$ccui-color-primary} 0%, #{$ccui-color-primary-hover} 70%, transparent) + ); + offset-anchor: 90% 50%; + offset-distance: 0%; + offset-path: rect(0 auto auto 0 round 100px); + offset-rotate: auto; + animation: ccui-border-beam-move var(--ccui-bb-duration) linear infinite; + will-change: offset-distance; + } + } + } + } +} + +@keyframes ccui-border-beam-move { + from { + offset-distance: 0%; + } + + to { + offset-distance: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .#{$cls-prefix}-border-beam__effect::before { + display: none; + } +} diff --git a/packages/ccui/ui/border-beam/src/border-beam.tsx b/packages/ccui/ui/border-beam/src/border-beam.tsx new file mode 100644 index 00000000..bdf17cd3 --- /dev/null +++ b/packages/ccui/ui/border-beam/src/border-beam.tsx @@ -0,0 +1,76 @@ +import type { CSSProperties } from 'vue' +import type { BorderBeamColor, BorderBeamColorStop, BorderBeamProps } from './border-beam-types' +import { computed, defineComponent } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { borderBeamProps, MAX_BEAM_COLOR_STOP_PERCENT } from './border-beam-types' +import './border-beam.scss' + +/** 把数字补 px,字符串原样返回 */ +function toLength(value: number | string): string { + return typeof value === 'number' ? `${value}px` : value +} + +function normalizeColor(value?: BorderBeamColor): BorderBeamColorStop[] { + if (typeof value === 'string') { + return value ? [{ color: value, percent: 0 }] : [] + } + return value ?? [] +} + +/** 末尾停靠点补到 100%,保证自定义渐变末端有可见拖尾 */ +function fillGradientEnd(items: BorderBeamColorStop[]): BorderBeamColorStop[] { + const last = items[items.length - 1] + if (!last || last.percent === 100) { + return items + } + return [...items, { ...last, percent: 100 }] +} + +/** 把用户的 0~100 映射进光带前 70%(保留尾段淡出),按比例缩放而非硬裁剪 */ +function mapStopPercent(percent: number): number { + const clamped = Math.min(Math.max(percent, 0), 100) + return Number(((clamped / 100) * MAX_BEAM_COLOR_STOP_PERCENT).toFixed(2)) +} + +/** 由单色或渐变停靠点构建光带渐变;无颜色时返回 undefined(由样式回退到主题主色) */ +function getBorderBeamGradient(value?: BorderBeamColor): string | undefined { + const stops = fillGradientEnd(normalizeColor(value)).map((item) => ({ + ...item, + percent: mapStopPercent(item.percent), + })) + if (!stops.length) { + return undefined + } + const colorStops = stops.map((item) => `${item.color} ${item.percent}%`).join(', ') + return `linear-gradient(to left, ${colorStops}, transparent)` +} + +export default defineComponent({ + name: 'CBorderBeam', + props: borderBeamProps, + setup(props: BorderBeamProps, { slots }) { + const ns = useNamespace('border-beam') + + const beamGradient = computed(() => getBorderBeamGradient(props.color)) + + const rootStyle = computed(() => { + const vars: Record = { + '--ccui-bb-outset': toLength(props.outset), + '--ccui-bb-border-width': `${props.borderWidth}px`, + '--ccui-bb-radius': `${props.borderRadius}px`, + '--ccui-bb-duration': `${props.duration}s`, + } + if (beamGradient.value) { + vars['--ccui-bb-beam-gradient'] = beamGradient.value + } + return vars as CSSProperties + }) + + return () => ( +
    + {slots.default?.()} + + ) + }, +}) diff --git a/packages/ccui/ui/border-beam/test/border-beam.test.ts b/packages/ccui/ui/border-beam/test/border-beam.test.ts new file mode 100644 index 00000000..26d451d1 --- /dev/null +++ b/packages/ccui/ui/border-beam/test/border-beam.test.ts @@ -0,0 +1,163 @@ +import { mount } from '@vue/test-utils' +import { h } from 'vue' +import { describe, expect, it } from 'vite-plus/test' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { Card } from '../../card' +import { BorderBeam, borderBeamPresetKeys, borderBeamPresets } from '../index' + +const ns = useNamespace('border-beam', true) + +describe('border-beam', () => { + it('mount component', () => { + const wrapper = mount(BorderBeam) + expect(wrapper.classes()).toContain('ccui-border-beam') + }) + + it('renders default slot content', () => { + const wrapper = mount(BorderBeam, { + slots: { + default: '
    Beam Content
    ', + }, + }) + expect(wrapper.find('.content').exists()).toBeTruthy() + expect(wrapper.find('.content').text()).toBe('Beam Content') + }) + + it('renders the effect layer', () => { + const wrapper = mount(BorderBeam) + expect(wrapper.find(ns.e('effect')).exists()).toBeTruthy() + }) + + it('marks the effect layer as decorative (aria-hidden)', () => { + const wrapper = mount(BorderBeam) + expect(wrapper.find(ns.e('effect')).attributes('aria-hidden')).toBe('true') + }) + + it('places the slot content before the effect layer in DOM order', () => { + const wrapper = mount(BorderBeam, { + slots: { default: 'inner' }, + }) + const root = wrapper.element + // 内容在前、流光层作为最后一个子节点覆盖其上 + expect(root.querySelector('.content')).toBeTruthy() + expect(root.lastElementChild?.classList.contains('ccui-border-beam__effect')).toBe(true) + }) + + it('wraps a ccui Card without swallowing it', () => { + const wrapper = mount(BorderBeam, { + slots: { + default: () => h(Card, { header: '智能助手' }, () => '流光卡片'), + }, + }) + expect(wrapper.find('.ccui-card').exists()).toBeTruthy() + expect(wrapper.find('.ccui-card').text()).toContain('流光卡片') + expect(wrapper.find(ns.e('effect')).exists()).toBeTruthy() + }) + + it('uses the theme default gradient (no inline gradient var) when color is omitted', () => { + const wrapper = mount(BorderBeam) + const style = wrapper.attributes('style') || '' + expect(style).not.toContain('--ccui-bb-beam-gradient') + }) + + it('builds the beam gradient from a single color (mapped into the first 70%)', () => { + const wrapper = mount(BorderBeam, { + props: { color: '#ff0000' }, + }) + const style = wrapper.attributes('style') || '' + expect(style).toContain('--ccui-bb-beam-gradient: linear-gradient(to left, #ff0000 0%, #ff0000 70%, transparent)') + }) + + it('builds the beam gradient from color stops scaled into the first 70%', () => { + const wrapper = mount(BorderBeam, { + props: { + color: [ + { color: '#1677ff', percent: 0 }, + { color: '#36cfc9', percent: 52 }, + { color: '#95de64', percent: 100 }, + ], + }, + }) + const style = wrapper.attributes('style') || '' + // 0→0% / 52→36.4% / 100→70%,末尾补 transparent + expect(style).toContain( + '--ccui-bb-beam-gradient: linear-gradient(to left, #1677ff 0%, #36cfc9 36.4%, #95de64 70%, transparent)', + ) + }) + + it('fills the gradient end to 100% (mapped to 70%) when the last stop is below 100', () => { + const wrapper = mount(BorderBeam, { + props: { + color: [ + { color: '#1677ff', percent: 0 }, + { color: '#95de64', percent: 40 }, + ], + }, + }) + const style = wrapper.attributes('style') || '' + // 末尾 #95de64 被补到 100%→70% + expect(style).toContain( + '--ccui-bb-beam-gradient: linear-gradient(to left, #1677ff 0%, #95de64 28%, #95de64 70%, transparent)', + ) + }) + + it('injects default css variables onto container', () => { + const wrapper = mount(BorderBeam) + const style = wrapper.attributes('style') || '' + expect(style).toContain('--ccui-bb-outset: 0px') + expect(style).toContain('--ccui-bb-border-width: 1px') + expect(style).toContain('--ccui-bb-radius: 8px') + expect(style).toContain('--ccui-bb-duration: 6s') + }) + + it('injects css variables from props onto container', () => { + const wrapper = mount(BorderBeam, { + props: { + borderWidth: 2, + borderRadius: 12, + outset: 4, + duration: 8, + }, + }) + const style = wrapper.attributes('style') || '' + expect(style).toContain('--ccui-bb-border-width: 2px') + expect(style).toContain('--ccui-bb-radius: 12px') + expect(style).toContain('--ccui-bb-outset: 4px') + expect(style).toContain('--ccui-bb-duration: 8s') + }) + + it('accepts string outset as-is', () => { + const wrapper = mount(BorderBeam, { + props: { outset: '1rem' }, + }) + const style = wrapper.attributes('style') || '' + expect(style).toContain('--ccui-bb-outset: 1rem') + }) + + describe('presets', () => { + it('ships 6 built-in color presets in a stable order', () => { + expect(borderBeamPresetKeys).toEqual(['ocean', 'sunset', 'aurora', 'forest', 'ember', 'nebula']) + }) + + it('each preset has a name and 3 gradient stops anchored at 0 and 100', () => { + borderBeamPresetKeys.forEach((key) => { + const preset = borderBeamPresets[key] + expect(preset.name).toBeTruthy() + expect(preset.color).toHaveLength(3) + expect(preset.color[0].percent).toBe(0) + expect(preset.color[preset.color.length - 1].percent).toBe(100) + }) + }) + + it('renders a preset color as a beam gradient', () => { + const wrapper = mount(BorderBeam, { + props: { color: borderBeamPresets.ocean.color }, + }) + const style = wrapper.attributes('style') || '' + // ocean: #1677ff 0 / #36cfc9 52 / #95de64 100 → 映射进前 70% + expect(style).toContain( + '--ccui-bb-beam-gradient: linear-gradient(to left, #1677ff 0%, #36cfc9 36.4%, #95de64 70%, transparent)', + ) + }) + }) +}) diff --git a/packages/ccui/ui/breadcrumb/index.ts b/packages/ccui/ui/breadcrumb/index.ts new file mode 100644 index 00000000..ccd12b34 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/index.ts @@ -0,0 +1,19 @@ +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..e8c408aa --- /dev/null +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss @@ -0,0 +1,52 @@ +@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 !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-secondary; + cursor: pointer; + + &:hover { + color: $ccui-color-text; + 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-text-quaternary; + 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..79b45638 --- /dev/null +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.tsx @@ -0,0 +1,70 @@ +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-3d/src/button-3d-types.ts b/packages/ccui/ui/button-3d/src/button-3d-types.ts index 5cc4d534..fb638ade 100644 --- a/packages/ccui/ui/button-3d/src/button-3d-types.ts +++ b/packages/ccui/ui/button-3d/src/button-3d-types.ts @@ -1,6 +1,6 @@ import type { ExtractPropTypes, PropType } from 'vue' -export type Button3DType = 'primary' | 'success' | 'warning' | 'danger' | 'info' +export type Button3DType = 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' export type Button3DSizeType = 'large' | 'default' | 'small' export type Button3DNativeType = 'button' | 'submit' | 'reset' diff --git a/packages/ccui/ui/button-3d/src/button-3d.scss b/packages/ccui/ui/button-3d/src/button-3d.scss index 14fdfc50..b50e62f4 100644 --- a/packages/ccui/ui/button-3d/src/button-3d.scss +++ b/packages/ccui/ui/button-3d/src/button-3d.scss @@ -1,17 +1,9 @@ @use '../../style-var/index.scss' as *; -.#{$cls-prefix}-button-3d { - --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%); +$button-3d-info-color: #909399; +$button-3d-info-active-color: #73767a; +.#{$cls-prefix}-button-3d { position: relative; background: transparent; cursor: pointer; @@ -77,16 +69,16 @@ .edge { background: linear-gradient( to left, - hsl(230, 80%, 32%) 0%, - hsl(230, 80%, 16%) 8%, - hsl(230, 80%, 16%) 92%, - hsl(230, 80%, 32%) 100% + $ccui-color-primary-active 0%, + $ccui-color-primary-active 8%, + $ccui-color-primary-active 92%, + $ccui-color-primary-active 100% ); } .front { - background: var(--primary-color); - color: #ffffff; + background: $ccui-color-primary; + color: $ccui-color-text-light-solid; } } @@ -94,16 +86,16 @@ .edge { background: linear-gradient( to left, - hsl(148, 60%, 28%) 0%, - hsl(148, 60%, 14%) 8%, - hsl(148, 60%, 14%) 92%, - hsl(148, 60%, 28%) 100% + $ccui-color-success-active 0%, + $ccui-color-success-active 8%, + $ccui-color-success-active 92%, + $ccui-color-success-active 100% ); } .front { - background: var(--success-color); - color: #ffffff; + background: $ccui-color-success; + color: $ccui-color-text-light-solid; } } @@ -111,16 +103,16 @@ .edge { background: linear-gradient( to left, - hsl(220, 8%, 36%) 0%, - hsl(220, 8%, 18%) 8%, - hsl(220, 8%, 18%) 92%, - hsl(220, 8%, 36%) 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: var(--info-color); - color: #ffffff; + background: $button-3d-info-color; + color: $ccui-color-text-light-solid; } } @@ -128,16 +120,16 @@ .edge { background: linear-gradient( to left, - hsl(32, 80%, 32%) 0%, - hsl(32, 80%, 16%) 8%, - hsl(32, 80%, 16%) 92%, - hsl(32, 80%, 32%) 100% + $ccui-color-warning-active 0%, + $ccui-color-warning-active 8%, + $ccui-color-warning-active 92%, + $ccui-color-warning-active 100% ); } .front { - background: var(--warning-color); - color: #ffffff; + background: $ccui-color-warning; + color: $ccui-color-text-light-solid; } } @@ -145,16 +137,16 @@ .edge { background: linear-gradient( to left, - hsl(2, 80%, 36%) 0%, - hsl(2, 80%, 18%) 8%, - hsl(2, 80%, 18%) 92%, - hsl(2, 80%, 36%) 100% + $ccui-color-error-active 0%, + $ccui-color-error-active 8%, + $ccui-color-error-active 92%, + $ccui-color-error-active 100% ); } .front { - background: var(--danger-color); - color: white; + background: $ccui-color-error; + color: $ccui-color-text-light-solid; } } diff --git a/packages/ccui/ui/button-3d/src/button-3d.tsx b/packages/ccui/ui/button-3d/src/button-3d.tsx index 4a017747..13ee06de 100644 --- a/packages/ccui/ui/button-3d/src/button-3d.tsx +++ b/packages/ccui/ui/button-3d/src/button-3d.tsx @@ -34,16 +34,14 @@ export default defineComponent({ - {props.loading - ? ( - <> - - Loading... - - ) - : ( - slots.default?.() - )} + {props.loading ? ( + <> + + Loading... + + ) : ( + slots.default?.() + )} ) diff --git a/packages/ccui/ui/button-3d/test/button-3d.test.ts b/packages/ccui/ui/button-3d/test/button-3d.test.ts index 4a7b0a3e..722dc447 100644 --- a/packages/ccui/ui/button-3d/test/button-3d.test.ts +++ b/packages/ccui/ui/button-3d/test/button-3d.test.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import Button3d from '../src/button-3d' describe('button3d', () => { diff --git a/packages/ccui/ui/button/index.ts b/packages/ccui/ui/button/index.ts index 77e91fb6..73f21ef3 100644 --- a/packages/ccui/ui/button/index.ts +++ b/packages/ccui/ui/button/index.ts @@ -1,11 +1,26 @@ import type { App } from 'vue' import Button from './src/button' +import ButtonGroup from './src/button-group' +// 平铺导出:模板用 ,TSX 用 import { ButtonGroup } +// 不做 React 风格的 Button.Group 静态属性挂载(详见 docs-notes/decisions/benchmark-principles.md) Button.install = function (app: App): void { app.component(Button.name!, Button) + app.component(ButtonGroup.name!, ButtonGroup) } -export { Button } +export { Button, ButtonGroup } + +export type { + ButtonGroupProps, + ButtonIconPosition, + ButtonLoading, + ButtonLoadingObject, + ButtonNativeType, + ButtonProps, + ButtonSizeType, + ButtonType, +} from './src/button-types' export default { title: 'Button 按钮', @@ -13,5 +28,6 @@ export default { status: '100%', install(app: App): void { app.component(Button.name!, Button) + app.component(ButtonGroup.name!, ButtonGroup) }, } diff --git a/packages/ccui/ui/button/src/button-group.scss b/packages/ccui/ui/button/src/button-group.scss new file mode 100644 index 00000000..43cb303e --- /dev/null +++ b/packages/ccui/ui/button/src/button-group.scss @@ -0,0 +1,36 @@ +@use '../../style-var/index.scss' as *; + +.#{$cls-prefix}-button-group { + display: inline-flex; + vertical-align: middle; + + // 相邻按钮:去掉中间圆角,让边框对齐 + > .#{$cls-prefix}-button { + position: relative; + + & + .#{$cls-prefix}-button { + margin-inline-start: -1px; + } + + &:not(:first-child):not(:last-child) { + border-radius: 0; + } + + &:first-child:not(:last-child) { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + + &:last-child:not(:first-child) { + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + // hover / focus 抬起当前按钮,避免边框被相邻覆盖 + &:hover, + &:focus, + &:active { + z-index: 2; + } + } +} diff --git a/packages/ccui/ui/button/src/button-group.tsx b/packages/ccui/ui/button/src/button-group.tsx new file mode 100644 index 00000000..9cd7d1de --- /dev/null +++ b/packages/ccui/ui/button/src/button-group.tsx @@ -0,0 +1,25 @@ +import type { ButtonGroupProps } from './button-types' +import { defineComponent, provide, toRef } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' +import { buttonGroupInjectionKey, buttonGroupProps } from './button-types' +import './button-group.scss' + +export default defineComponent({ + name: 'CButtonGroup', + props: buttonGroupProps, + setup(props: ButtonGroupProps, { slots }) { + const ns = useNamespace('button-group') + + // 把 size / disabled 注入给子按钮;子按钮显式 prop 仍优先 + provide(buttonGroupInjectionKey, { + size: toRef(props, 'size'), + disabled: toRef(props, 'disabled'), + }) + + return () => ( +
    + {slots.default?.()} +
    + ) + }, +}) diff --git a/packages/ccui/ui/button/src/button-types.ts b/packages/ccui/ui/button/src/button-types.ts index 9f3955ce..7fc8c66a 100644 --- a/packages/ccui/ui/button/src/button-types.ts +++ b/packages/ccui/ui/button/src/button-types.ts @@ -1,9 +1,39 @@ -import type { ExtractPropTypes, PropType } from 'vue' +import type { ExtractPropTypes, InjectionKey, PropType, Ref, VNode } from 'vue' -export type ButtonType = 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text' +/** + * Button 取值集合(联合): + * + * - 标准取值:`'primary' | 'default' | 'dashed' | 'link' | 'text'` + * - 语义色快捷:`'success' | 'warning' | 'danger' | 'info'` + * (等价于 `danger=true` / `color='success'` 等组合,二选一即可) + */ +export type ButtonType = + | '' + | 'primary' + | 'default' + | 'dashed' + | 'link' + | 'text' + | 'success' + | 'warning' + | 'danger' + | 'info' -export type ButtonSizeType = 'large' | 'default' | 'small' +export type ButtonSizeType = '' | 'large' | 'default' | 'small' export type ButtonNativeType = 'button' | 'submit' | 'reset' +export type ButtonIconPosition = 'start' | 'end' + +/** + * `loading` 复合配置: + * + * - `boolean`:立刻进入 loading + * - `{ delay, icon }`:延迟 `delay` ms 后再进入 loading;自定义 loading 图标 + */ +export interface ButtonLoadingObject { + delay?: number + icon?: VNode | string +} +export type ButtonLoading = boolean | ButtonLoadingObject export const buttonProps = { type: { @@ -18,14 +48,23 @@ export const buttonProps = { type: Boolean, default: false, }, + /** + * 朴素按钮:背景透明,文字与边框跟主色。 + */ plain: { type: Boolean, default: false, }, + /** + * 圆角按钮。 + */ round: { type: Boolean, default: false, }, + /** + * 圆形按钮(常用于纯图标场景)。 + */ circle: { type: Boolean, default: false, @@ -34,18 +73,104 @@ export const buttonProps = { type: Boolean, default: false, }, + /** + * 原生 ` + const iconNode = (() => { + if (isLoading.value) { + // loading icon 优先用 prop 提供的,否则默认 SVG-less span + if (loadingIconNode.value) { + return typeof loadingIconNode.value === 'string' ? ( + isIconifyName(loadingIconNode.value) ? ( + + ) : ( + + ) + ) : ( + (loadingIconNode.value as VNode) + ) + } + return + } + if (slots.icon) return {slots.icon()} + if (props.icon) { + return isIconifyName(props.icon) ? ( + + ) : ( + + ) + } + return null + })() + + const contentNode = (() => { + if (!slots.default) return null + const text = insertedContent.value + return {text ?? slots.default()} + })() + + const children = [iconNode, contentNode].filter(Boolean) + + // 链接按钮:href 设置时渲染 + if (props.href !== undefined) { + return h( + 'a', + { + class: butCls.value, + style: customColorStyle.value, + href: resolvedDisabled.value || isLoading.value ? undefined : props.href, + target: props.target, + // disabled 没有原生支持,用 aria-disabled + click 拦截 + 'aria-disabled': resolvedDisabled.value || isLoading.value ? 'true' : undefined, + role: 'button', + tabindex: resolvedDisabled.value ? -1 : 0, + onClick, + }, + children, + ) + } + + return h( + 'button', + { + class: butCls.value, + style: customColorStyle.value, + type: props.nativeType, + autofocus: props.autofocus || undefined, + disabled: resolvedDisabled.value || isLoading.value, + onClick, + }, + children, ) } }, diff --git a/packages/ccui/ui/button/test/button.test.ts b/packages/ccui/ui/button/test/button.test.ts index 62c7f99c..9680c999 100644 --- a/packages/ccui/ui/button/test/button.test.ts +++ b/packages/ccui/ui/button/test/button.test.ts @@ -1,6 +1,6 @@ import type { ButtonSizeType, ButtonType } from '../src/button-types' import { mount, shallowMount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { useNamespace } from '../../shared/hooks/use-namespace' import { Button } from '../index' @@ -43,7 +43,8 @@ describe('button', () => { it('dom', () => { const wrapper = createShallowWrapper({}, { default: '确定' }) expect(wrapper.find(baseClass).exists()).toBeTruthy() - expect(wrapper.find(baseClass).text()).toBe('确定') + // 注意:autoInsertSpace 默认 true,2 字 CJK 自动插空格 + expect(wrapper.find(baseClass).text()).toBe('确 定') wrapper.unmount() }) @@ -115,10 +116,13 @@ describe('button', () => { }) it('renders icon slot when provided', () => { - const wrapper = createWrapper({}, { - icon: '', - default: 'Like', - }) + const wrapper = createWrapper( + {}, + { + icon: '', + default: 'Like', + }, + ) expect(wrapper.find('.cc-icon-heart').exists()).toBe(true) wrapper.unmount() }) @@ -153,4 +157,246 @@ describe('button', () => { expect(wrapper.find(ns.e('loading-icon')).text()).toBe('') wrapper.unmount() }) + + it('emits component click event when enabled', async () => { + const wrapper = createWrapper() + await wrapper.trigger('click') + expect(wrapper.emitted('click')?.length).toBe(1) + }) + + it('does not emit component click event when disabled or loading', async () => { + const disabled = createWrapper({ disabled: true }) + await disabled.trigger('click') + expect(disabled.emitted('click')).toBeUndefined() + + const loading = createWrapper({ loading: true }) + await loading.trigger('click') + expect(loading.emitted('click')).toBeUndefined() + }) + + it('renders button without default content', () => { + const wrapper = mount(Button) + expect(wrapper.find(ns.e('content')).exists()).toBe(false) + }) + + describe('nativeType', () => { + it('nativeType="submit" 透传到 `, + }) + const btns = wrapper.findAll(baseClass) + expect(btns.length).toBe(2) + btns.forEach((btn) => { + expect(btn.classes()).toContain('ccui-button--large') + }) + }) + + it('Group 的 disabled 注入子按钮', async () => { + const { ButtonGroup } = await import('../index') + const wrapper = mount({ + components: { Button, ButtonGroup }, + template: ``, + }) + const btn = wrapper.find('button') + expect(btn.attributes('disabled')).toBe('') + }) + + it('子按钮显式 size 优先于 Group', async () => { + const { ButtonGroup } = await import('../index') + const wrapper = mount({ + components: { Button, ButtonGroup }, + template: ``, + }) + const btn = wrapper.find(baseClass) + expect(btn.classes()).toContain('ccui-button--small') + expect(btn.classes()).not.toContain('ccui-button--large') + }) + }) + + describe('新 type 取值', () => { + it('type="default" 仍渲染基类(无额外 modifier 误伤)', () => { + const wrapper = createShallowWrapper({ type: 'default' }) + expect(wrapper.find(baseClass).exists()).toBe(true) + expect(wrapper.find(ns.m('default')).exists()).toBe(true) + }) + + it('type="dashed" 渲染 --dashed', () => { + const wrapper = createShallowWrapper({ type: 'dashed' }) + expect(wrapper.find(ns.m('dashed')).exists()).toBe(true) + }) + + it('type="link" 渲染 --link', () => { + const wrapper = createShallowWrapper({ type: 'link' }) + expect(wrapper.find(ns.m('link')).exists()).toBe(true) + }) + }) + + describe('自定义 color(任意 CSS 字符串)', () => { + it('实心型 type 注入 background-color + border-color', () => { + const wrapper = createShallowWrapper({ type: 'primary', color: '#ff7875' }) + const style = wrapper.find('button').attributes('style') || '' + expect(style).toContain('background-color: rgb(255, 120, 117)') + expect(style).toContain('border-color: rgb(255, 120, 117)') + }) + + it('描边型 type(默认)注入 color + border-color', () => { + const wrapper = createShallowWrapper({ color: '#1677ff' }) + const style = wrapper.find('button').attributes('style') || '' + expect(style).toContain('color: rgb(22, 119, 255)') + expect(style).toContain('border-color: rgb(22, 119, 255)') + }) + + it('text / link type 仅注入 color', () => { + const wrapper = createShallowWrapper({ type: 'text', color: '#52c41a' }) + const style = wrapper.find('button').attributes('style') || '' + expect(style).toContain('color: rgb(82, 196, 26)') + expect(style).not.toContain('background-color') + expect(style).not.toContain('border-color') + }) + + it('未传 color 不污染 style 属性', () => { + const wrapper = createShallowWrapper({ type: 'primary' }) + // 没设置 style 时属性应为空或不存在 + const style = wrapper.find('button').attributes('style') + expect(style === undefined || style === '').toBe(true) + }) + + it('color 在 (href 设置)上同样生效', () => { + const wrapper = createShallowWrapper({ href: 'https://example.com', color: '#ff7875' }) + const style = wrapper.find('a').attributes('style') || '' + expect(style).toContain('color: rgb(255, 120, 117)') + }) + }) }) diff --git a/packages/ccui/ui/calendar/index.ts b/packages/ccui/ui/calendar/index.ts index 8f69b969..d0e9561d 100644 --- a/packages/ccui/ui/calendar/index.ts +++ b/packages/ccui/ui/calendar/index.ts @@ -9,7 +9,7 @@ export { Calendar } export default { title: 'Calendar 日历', - category: '数据展示', + category: '数据录入', status: '100%', install(app: App): void { app.component(Calendar.name!, Calendar) diff --git a/packages/ccui/ui/calendar/src/calendar-types.ts b/packages/ccui/ui/calendar/src/calendar-types.ts index 085b6a14..f15933e8 100644 --- a/packages/ccui/ui/calendar/src/calendar-types.ts +++ b/packages/ccui/ui/calendar/src/calendar-types.ts @@ -1,14 +1,53 @@ -import type { ExtractPropTypes } from 'vue' +import type { ExtractPropTypes, PropType } from 'vue' +import type { CcSemanticClasses, CcSemanticStyles } from '../../shared/hooks/use-semantic' +import type { DateValue } from '../../shared/utils/date' + +export type CalendarValueFormat = 'string' | 'date' | 'number' export const calendarProps = { + /** + * 选中日期。支持 string / Date / number(timestamp) / Dayjs / null。 + * 字符串按 `format` 解析;解析失败回退到今天。 + */ modelValue: { - type: Date, - default: new Date(), + type: [String, Number, Date, Object] as PropType, + default: undefined, + }, + /** + * 日期解析 / 格式化模板(dayjs format token)。 + */ + format: { + type: String, + default: 'YYYY-MM-DD', + }, + /** + * v-model 输出协议: + * - `'string'`:按 `format` 输出字符串(默认) + * - `'date'`:输出原生 `Date` + * - `'number'`:输出毫秒级 timestamp + */ + valueFormat: { + type: String as PropType, + default: 'string', }, readOnly: { type: Boolean, default: false, }, + /** + * 语义化 DOM className 注入。可用 key:`root` / `header` / `body` / `cell`。 + */ + classNames: { + type: Object as PropType, + default: undefined, + }, + /** + * 语义化 DOM style 注入。可用 key 与 classNames 一致。 + */ + styles: { + type: Object as PropType, + default: undefined, + }, } as const export type CalendarProps = ExtractPropTypes diff --git a/packages/ccui/ui/calendar/src/calendar.tsx b/packages/ccui/ui/calendar/src/calendar.tsx index 479803e0..6dcd6074 100644 --- a/packages/ccui/ui/calendar/src/calendar.tsx +++ b/packages/ccui/ui/calendar/src/calendar.tsx @@ -2,49 +2,54 @@ import type { Ref } from 'vue' import type { CalendarProps, dateItem } from './calendar-types' import dayjs from 'dayjs' import { computed, defineComponent, ref, watch } from 'vue' +import { useConfig } from '../../config-provider/src/config-provider' import { useNamespace } from '../../shared/hooks/use-namespace' +import { emitValue, toDayjs } from '../../shared/utils/date' import { calendarProps } from './calendar-types' import './calendar.scss' +const DEFAULT_WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + export default defineComponent({ name: 'CCalendar', props: calendarProps, emits: ['change', 'update:modelValue'], setup(props: CalendarProps, { emit, slots }) { const ns = useNamespace('calendar') + const cfg = useConfig() + + const localeCalendar = computed(() => cfg.locale?.Calendar ?? {}) + const weekList = computed(() => { + const fromLocale = localeCalendar.value.weekdaysShort + return fromLocale && fromLocale.length === 7 ? fromLocale : DEFAULT_WEEKDAYS + }) + const monthFormat = computed(() => localeCalendar.value.monthFormat || 'YYYY-MM') + const prevLabel = computed(() => localeCalendar.value.prevMonthLabel || '上个月') + const nextLabel = computed(() => localeCalendar.value.nextMonthLabel || '下个月') + const todayLabel = computed(() => localeCalendar.value.todayLabel || '今天') + + const parseValue = () => toDayjs(props.modelValue, props.format) ?? dayjs() // 当前天 选中天 - const currentDate = ref( - props.modelValue - ? dayjs(props.modelValue).format('YYYY-MM-DD') - : dayjs().format('YYYY-MM-DD'), - ) + const currentDate = ref(parseValue().format('YYYY-MM-DD')) // 当前月 - const currentMonth = ref( - props.modelValue - ? dayjs(props.modelValue).format('YYYY-MM') - : dayjs().format('YYYY-MM'), - ) + const currentMonth = ref(parseValue().format('YYYY-MM')) // 当前展示的日期列表 const curDateList: Ref = ref([]) - const weekList = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] // 根据月份计算当前展示日期的数组 const generatedDate = (month?: string) => { // 获取当月第一天是周几 const whichDay = dayjs(month).startOf('month').day() // 计算开始开始日期 7 减去当月第一天是周几(获取到的周几是 0123456, 其中0代表周日) - const startDate = dayjs( - dayjs(month).subtract(7 - whichDay, 'day'), - ).format('YYYY-MM-DD') + const startDate = dayjs(dayjs(month).subtract(7 - whichDay, 'day')).format('YYYY-MM-DD') // 整理数据 // 生成长度42的数组,为什么是42呢? 因为有的月份是28 或者30 31, 35 会导致某些月份展示不全。 - curDateList.value = [...Array.from({ length: 42 })] - .map((item, index) => index) - .reduce((acc: Array, index: number) => { + curDateList.value = Array.from({ length: 42 }, (_, index) => index).reduce( + (acc: Array, index: number) => { // 获取展示的日期 const date = dayjs(startDate) .add(index + 1, 'day') @@ -59,11 +64,13 @@ export default defineComponent({ index: index + 1, date, day: dateList[dateList.length - 1], - week: weekList[week], + week: weekList.value[week], }) // 返回数组 return acc - }, []) + }, + [], + ) } // 初始化 @@ -78,8 +85,9 @@ export default defineComponent({ generatedDate(currentMonth.value) } - emit('update:modelValue', new Date(currentDate.value)) - emit('change', currentDate.value) + const out = emitValue(dayjs(currentDate.value), props.valueFormat, props.format) + emit('update:modelValue', out) + emit('change', out) } // 上一月 下一月 当前月 @@ -92,9 +100,7 @@ export default defineComponent({ // 上个月 if (type === 'lastMonth') { - month = dayjs(currentMonth.value) - .subtract(1, 'month') - .format('YYYY-MM') + month = dayjs(currentMonth.value).subtract(1, 'month').format('YYYY-MM') } // 传入YYYY-MM 格式的月份 格式化后会获取到当月的第一天 @@ -105,10 +111,10 @@ export default defineComponent({ watch( () => props.modelValue, () => { - currentDate.value = dayjs(props.modelValue).format('YYYY-MM-DD') + currentDate.value = parseValue().format('YYYY-MM-DD') // 月份不同 重新生成日历 if (!currentDate.value.includes(currentMonth.value)) { - currentMonth.value = dayjs(props.modelValue).format('YYYY-MM') + currentMonth.value = parseValue().format('YYYY-MM') generatedDate(currentMonth.value) } }, @@ -139,7 +145,8 @@ export default defineComponent({ onClick={() => { setCurrentDate(dateCellOpts.date) }} - class={className} + class={[className, props.classNames?.cell]} + style={props.styles?.cell} > {slots.dateCell ? slots.dateCell(dateCellOpts) : dateCellOpts.day}
    @@ -148,14 +155,14 @@ export default defineComponent({ }) // header 周 列表 - const weekItemList = weekList.map((item) => { - return
    {item}
    - }) + const weekItemList = computed(() => weekList.value.map((item) =>
    {item}
    )) + + const monthLabel = computed(() => dayjs(currentMonth.value).format(monthFormat.value)) const defaultHeader = () => { return ( -
    -
    {currentMonth.value}
    +
    +
    {monthLabel.value}
    - 上个月 + {prevLabel.value} - 今天 + {todayLabel.value} - 下个月 + {nextLabel.value}
    ) } + // header slot 的富作用域。把 navigation 工具 + 当前态一起暴露给 slot,让用户自定义工具栏时不必从外部重写月份切换逻辑。 + // 同时保留旧 string 形式的 .value 字段(旧 demo 模板 `{{ d.value }}` 可继续工作;裸 `{{ d }}` 会显示 [object Object],需迁移)。 + // 暴露 token:value / currentMonth / setDate / changeMonth。 + const headerScope = computed(() => ({ + value: currentDate.value, + currentMonth: currentMonth.value, + setDate: (date: string) => setCurrentDate(date), + changeMonth: (direction: 'lastMonth' | 'nextMonth') => changeMonth(direction), + })) + return () => ( -
    - {slots.header ? slots.header(currentDate.value) : defaultHeader()} -
    {weekItemList}
    -
    {dateItemList.value}
    +
    + {slots.header ? slots.header(headerScope.value) : defaultHeader()} +
    {weekItemList.value}
    +
    + {dateItemList.value} +
    ) }, diff --git a/packages/ccui/ui/calendar/test/calendar.test.ts b/packages/ccui/ui/calendar/test/calendar.test.ts index e0876d56..918031a6 100644 --- a/packages/ccui/ui/calendar/test/calendar.test.ts +++ b/packages/ccui/ui/calendar/test/calendar.test.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { Button } from '../../button/index' import { useNamespace } from '../../shared/hooks/use-namespace' import { Calendar } from '../index' @@ -43,11 +43,14 @@ describe('calendar', () => { }) it('slots', async () => { - const wrapper = createWrapper({}, { - dateCell: `