sparkdesign 0.0.1 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -4
- package/dist/commands/add.js +93 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/list.js +25 -0
- package/dist/index.js +37 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/registry.js +34 -0
- package/dist/utils/tokens.js +176 -0
- package/dist/utils/transform.js +19 -0
- package/package.json +33 -10
- package/registry/__tests__/basic/button.test.tsx +333 -0
- package/registry/__tests__/chat/markdown.test.tsx +387 -0
- package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
- package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
- package/registry/basic/alert-dialog.tsx +180 -0
- package/registry/basic/avatar.tsx +120 -0
- package/registry/basic/button.tsx +100 -0
- package/registry/basic/collapse.tsx +94 -0
- package/registry/basic/collapsible-card.tsx +230 -0
- package/registry/basic/collapsible.tsx +21 -0
- package/registry/basic/dropdown-menu.tsx +254 -0
- package/registry/basic/icon-button.tsx +66 -0
- package/registry/basic/icons-inline.tsx +206 -0
- package/registry/basic/kbd.tsx +50 -0
- package/registry/basic/option-list.tsx +125 -0
- package/registry/basic/pagination.tsx +132 -0
- package/registry/basic/progress.tsx +42 -0
- package/registry/basic/radio-group.tsx +69 -0
- package/registry/basic/resizable.tsx +67 -0
- package/registry/basic/scrollbar.tsx +114 -0
- package/registry/basic/select.tsx +177 -0
- package/registry/basic/shimmering-text.tsx +115 -0
- package/registry/basic/sidebar-menu.tsx +177 -0
- package/registry/basic/skeleton.tsx +33 -0
- package/registry/basic/slider.tsx +55 -0
- package/registry/basic/sonner.tsx +104 -0
- package/registry/basic/spinner.tsx +17 -0
- package/registry/basic/switch.tsx +49 -0
- package/registry/basic/table.tsx +117 -0
- package/registry/basic/tabs.tsx +85 -0
- package/registry/basic/tag.tsx +161 -0
- package/registry/basic/theme-from-document.ts +10 -0
- package/registry/basic/toggle.tsx +223 -0
- package/registry/basic/tooltip.tsx +80 -0
- package/registry/basic/typography.tsx +201 -0
- package/registry/chat/ask-user-part.tsx +70 -0
- package/registry/chat/browser-action-part.tsx +166 -0
- package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
- package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
- package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
- package/registry/chat/chat-input/compound.tsx +334 -0
- package/registry/chat/chat-input/context.tsx +189 -0
- package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
- package/registry/chat/chat-input/index.tsx +123 -0
- package/registry/chat/chat-input/types.ts +77 -0
- package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
- package/registry/chat/code-block-part.tsx +151 -0
- package/registry/chat/file-attachment.tsx +44 -0
- package/registry/chat/file-card.tsx +68 -0
- package/registry/chat/file-review-part.tsx +259 -0
- package/registry/chat/folder-button.tsx +169 -0
- package/registry/chat/generated-images-grid.tsx +56 -0
- package/registry/chat/generation-status-bar.tsx +72 -0
- package/registry/chat/hint-banner.tsx +165 -0
- package/registry/chat/image-attachment.tsx +166 -0
- package/registry/chat/image-generating.tsx +281 -0
- package/registry/chat/markdown.tsx +146 -0
- package/registry/chat/mermaid-part.tsx +90 -0
- package/registry/chat/permission-card.tsx +178 -0
- package/registry/chat/plan-part.tsx +168 -0
- package/registry/chat/queue-indicator.tsx +234 -0
- package/registry/chat/reasoning-step/compound.tsx +336 -0
- package/registry/chat/reasoning-step/context.tsx +114 -0
- package/registry/chat/reasoning-step/index.tsx +45 -0
- package/registry/chat/reasoning-step/types.ts +109 -0
- package/registry/chat/related-prompts.tsx +91 -0
- package/registry/chat/response/compound.tsx +210 -0
- package/registry/chat/response/context.tsx +200 -0
- package/registry/chat/response/index.tsx +87 -0
- package/registry/chat/response/types.ts +123 -0
- package/registry/chat/send-button.tsx +94 -0
- package/registry/chat/streaming-markdown-block.tsx +111 -0
- package/registry/chat/task-part.tsx +109 -0
- package/registry/chat/terminal-code-block-part.tsx +69 -0
- package/registry/chat/thinking-indicator.tsx +91 -0
- package/registry/chat/tool-invocation-card.tsx +132 -0
- package/registry/chat/user-message.tsx +38 -0
- package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
- package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
- package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
- package/registry/chat/user-question/compound.tsx +324 -0
- package/registry/chat/user-question/context.tsx +456 -0
- package/registry/chat/user-question/index.tsx +95 -0
- package/registry/chat/user-question/types.ts +61 -0
- package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
- package/registry/chat/user-question/useUserQuestionState.ts +165 -0
- package/registry/chat/user-question-answer.tsx +62 -0
- package/registry/lib/file-icon-maps.ts +150 -0
- package/registry/lib/use-mermaid-render.ts +76 -0
- package/registry/lib/utils.ts +6 -0
- package/registry/meta.json +1 -0
- package/registry/tokens/index.css +31 -0
- package/registry/tokens/scale/computed.css +103 -0
- package/registry/tokens/scale/config.css +110 -0
- package/registry/tokens/scale/index.css +30 -0
- package/registry/tokens/scale/presets/compact.css +30 -0
- package/registry/tokens/scale/presets/dense.css +64 -0
- package/registry/tokens/scale/presets/sharp.css +40 -0
- package/registry/tokens/scale/presets/soft.css +16 -0
- package/registry/tokens/scale.css +13 -0
- package/registry/tokens/scrollbar-utility.css +35 -0
- package/registry/tokens/theme.css +633 -0
- package/registry/tokens/themes/dark-parchment.css +132 -0
- package/registry/tokens/themes/dark-qoder.css +132 -0
- package/registry/tokens/themes/light-parchment.css +123 -0
- package/registry/tokens/themes/light-qoder.css +131 -0
- package/index.js +0 -5
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: cwd, registryRoot
|
|
3
|
+
* [OUTPUT]: 见各函数
|
|
4
|
+
* [POS]: cli/src/utils/tokens.ts
|
|
5
|
+
*
|
|
6
|
+
* [PROTOCOL]: 文件逻辑变更时同步更新此 Header
|
|
7
|
+
* - init:ensureDesignTokens 注入到用户入口 CSS(globals/index.css)
|
|
8
|
+
* - add:不改入口;writeQoderTokensFile 生成独立 qoder-tokens.css,由用户自行 import
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
const TOKEN_MARKER = 'Spark Design tokens';
|
|
13
|
+
/** 辅助函数:读取文件,不存在则返回空字符串 */
|
|
14
|
+
async function readFileIfExists(filePath) {
|
|
15
|
+
if (await fs.pathExists(filePath)) {
|
|
16
|
+
return fs.readFile(filePath, 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
/** 辅助函数:读取 scale 目录下的所有文件并内联 */
|
|
21
|
+
async function readScaleContents(registryRoot) {
|
|
22
|
+
const scaleDir = path.join(registryRoot, 'tokens', 'scale');
|
|
23
|
+
const scaleLegacy = path.join(registryRoot, 'tokens', 'scale.css');
|
|
24
|
+
// 新结构:读取 scale/ 目录
|
|
25
|
+
if (await fs.pathExists(scaleDir)) {
|
|
26
|
+
const configContent = await readFileIfExists(path.join(scaleDir, 'config.css'));
|
|
27
|
+
const computedContent = await readFileIfExists(path.join(scaleDir, 'computed.css'));
|
|
28
|
+
const presetsDir = path.join(scaleDir, 'presets');
|
|
29
|
+
const compactContent = await readFileIfExists(path.join(presetsDir, 'compact.css'));
|
|
30
|
+
const softContent = await readFileIfExists(path.join(presetsDir, 'soft.css'));
|
|
31
|
+
const sharpContent = await readFileIfExists(path.join(presetsDir, 'sharp.css'));
|
|
32
|
+
const denseContent = await readFileIfExists(path.join(presetsDir, 'dense.css'));
|
|
33
|
+
return [configContent, computedContent, compactContent, softContent, sharpContent, denseContent]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join('\n\n');
|
|
36
|
+
}
|
|
37
|
+
// 兼容旧结构:读取单文件 scale.css
|
|
38
|
+
if (await fs.pathExists(scaleLegacy)) {
|
|
39
|
+
const content = await fs.readFile(scaleLegacy, 'utf-8');
|
|
40
|
+
// 如果是 @import 入口,跳过
|
|
41
|
+
if (content.includes('@import "./scale/')) {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
/** 项目里是否已有 token(入口 CSS 内联了变量、或已 import qoder-tokens.css) */
|
|
49
|
+
export async function projectHasTokens(cwd) {
|
|
50
|
+
const candidates = [
|
|
51
|
+
path.join(cwd, 'src', 'app', 'globals.css'),
|
|
52
|
+
path.join(cwd, 'src', 'index.css'),
|
|
53
|
+
path.join(cwd, 'src', 'main.css'),
|
|
54
|
+
path.join(cwd, 'src', 'App.css'),
|
|
55
|
+
];
|
|
56
|
+
for (const p of candidates) {
|
|
57
|
+
if (await fs.pathExists(p)) {
|
|
58
|
+
const content = await fs.readFile(p, 'utf-8');
|
|
59
|
+
if (content.includes('--color-primary:') ||
|
|
60
|
+
content.includes(TOKEN_MARKER) ||
|
|
61
|
+
content.includes('qoder-tokens.css')) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/** 仅 init 使用:把 tokens 注入到用户入口 CSS(globals.css / index.css) */
|
|
69
|
+
export async function ensureDesignTokens(cwd, registryRoot) {
|
|
70
|
+
// 新结构:使用 index.css 统一入口
|
|
71
|
+
const indexPath = path.join(registryRoot, 'tokens', 'index.css');
|
|
72
|
+
// 兼容旧结构
|
|
73
|
+
const themePath = path.join(registryRoot, 'tokens', 'theme.css');
|
|
74
|
+
const scalePath = path.join(registryRoot, 'tokens', 'scale.css');
|
|
75
|
+
const scrollbarPath = path.join(registryRoot, 'tokens', 'scrollbar-utility.css');
|
|
76
|
+
const themesDir = path.join(registryRoot, 'tokens', 'themes');
|
|
77
|
+
// 检查是否有可用的 tokens 文件
|
|
78
|
+
const hasNewStructure = await fs.pathExists(indexPath);
|
|
79
|
+
const hasLegacyStructure = (await fs.pathExists(themePath)) && (await fs.pathExists(scalePath));
|
|
80
|
+
if (!hasNewStructure && !hasLegacyStructure) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (await projectHasTokens(cwd)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let cssEntry = path.join(cwd, 'src', 'app', 'globals.css');
|
|
87
|
+
if (!(await fs.pathExists(cssEntry))) {
|
|
88
|
+
cssEntry = path.join(cwd, 'src', 'index.css');
|
|
89
|
+
}
|
|
90
|
+
if (!(await fs.pathExists(cssEntry))) {
|
|
91
|
+
cssEntry = path.join(cwd, 'src', 'index.css');
|
|
92
|
+
await fs.ensureDir(path.dirname(cssEntry));
|
|
93
|
+
}
|
|
94
|
+
let tokensBlock;
|
|
95
|
+
if (hasNewStructure && (await fs.pathExists(themesDir))) {
|
|
96
|
+
// 新结构:读取拆分的主题文件并内联
|
|
97
|
+
const scaleContent = await readScaleContents(registryRoot);
|
|
98
|
+
const lightContent = await readFileIfExists(path.join(themesDir, 'light-qoder.css'));
|
|
99
|
+
const darkContent = await readFileIfExists(path.join(themesDir, 'dark-qoder.css'));
|
|
100
|
+
const lightParchmentContent = await readFileIfExists(path.join(themesDir, 'light-parchment.css'));
|
|
101
|
+
const darkParchmentContent = await readFileIfExists(path.join(themesDir, 'dark-parchment.css'));
|
|
102
|
+
const scrollbarContent = await readFileIfExists(scrollbarPath);
|
|
103
|
+
tokensBlock = `/* ${TOKEN_MARKER} - 由 npx sparkdesign init 写入,驱动组件样式 */\n` +
|
|
104
|
+
`${scaleContent}\n\n` +
|
|
105
|
+
`${lightContent}\n\n` +
|
|
106
|
+
`${darkContent}\n\n` +
|
|
107
|
+
`${lightParchmentContent}\n\n` +
|
|
108
|
+
`${darkParchmentContent}\n` +
|
|
109
|
+
(scrollbarContent ? `\n${scrollbarContent}\n` : '');
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// 兼容旧结构
|
|
113
|
+
const themeContent = await fs.readFile(themePath, 'utf-8');
|
|
114
|
+
const scaleContent = await fs.readFile(scalePath, 'utf-8');
|
|
115
|
+
const scrollbarContent = (await fs.pathExists(scrollbarPath))
|
|
116
|
+
? await fs.readFile(scrollbarPath, 'utf-8')
|
|
117
|
+
: '';
|
|
118
|
+
tokensBlock =
|
|
119
|
+
`/* ${TOKEN_MARKER} - 由 npx sparkdesign init 写入,驱动组件样式 */\n${scaleContent}\n\n${themeContent}\n` +
|
|
120
|
+
(scrollbarContent ? `\n${scrollbarContent}\n` : '');
|
|
121
|
+
}
|
|
122
|
+
const existing = (await fs.pathExists(cssEntry)) ? await fs.readFile(cssEntry, 'utf-8') : '';
|
|
123
|
+
const newContent = tokensBlock + (existing ? '\n' + existing : '');
|
|
124
|
+
await fs.writeFile(cssEntry, newContent, 'utf-8');
|
|
125
|
+
return cssEntry;
|
|
126
|
+
}
|
|
127
|
+
const DEDICATED_TOKENS_FILE = 'qoder-tokens.css';
|
|
128
|
+
/** 仅 add 使用:在 src 下生成独立 token 文件,不修改用户入口。若已存在则不覆盖。返回路径及是否本次创建。 */
|
|
129
|
+
export async function writeQoderTokensFile(cwd, registryRoot) {
|
|
130
|
+
const indexPath = path.join(registryRoot, 'tokens', 'index.css');
|
|
131
|
+
const themePath = path.join(registryRoot, 'tokens', 'theme.css');
|
|
132
|
+
const scalePath = path.join(registryRoot, 'tokens', 'scale.css');
|
|
133
|
+
const scrollbarPath = path.join(registryRoot, 'tokens', 'scrollbar-utility.css');
|
|
134
|
+
const themesDir = path.join(registryRoot, 'tokens', 'themes');
|
|
135
|
+
const targetPath = path.join(cwd, 'src', DEDICATED_TOKENS_FILE);
|
|
136
|
+
if (await fs.pathExists(targetPath)) {
|
|
137
|
+
return { path: targetPath, created: false };
|
|
138
|
+
}
|
|
139
|
+
const hasNewStructure = await fs.pathExists(indexPath);
|
|
140
|
+
const hasLegacyStructure = (await fs.pathExists(themePath)) && (await fs.pathExists(scalePath));
|
|
141
|
+
if (!hasNewStructure && !hasLegacyStructure) {
|
|
142
|
+
return { path: targetPath, created: false };
|
|
143
|
+
}
|
|
144
|
+
let content;
|
|
145
|
+
if (hasNewStructure && (await fs.pathExists(themesDir))) {
|
|
146
|
+
// 新结构:读取拆分的主题文件并内联
|
|
147
|
+
const scaleContent = await readScaleContents(registryRoot);
|
|
148
|
+
const lightContent = await readFileIfExists(path.join(themesDir, 'light-qoder.css'));
|
|
149
|
+
const darkContent = await readFileIfExists(path.join(themesDir, 'dark-qoder.css'));
|
|
150
|
+
const lightParchmentContent = await readFileIfExists(path.join(themesDir, 'light-parchment.css'));
|
|
151
|
+
const darkParchmentContent = await readFileIfExists(path.join(themesDir, 'dark-parchment.css'));
|
|
152
|
+
const scrollbarContent = await readFileIfExists(scrollbarPath);
|
|
153
|
+
content = `/* ${TOKEN_MARKER} - 在应用入口 import 此文件以驱动组件样式 */\n` +
|
|
154
|
+
`${scaleContent}\n\n` +
|
|
155
|
+
`${lightContent}\n\n` +
|
|
156
|
+
`${darkContent}\n\n` +
|
|
157
|
+
`${lightParchmentContent}\n\n` +
|
|
158
|
+
`${darkParchmentContent}\n` +
|
|
159
|
+
(scrollbarContent ? `\n${scrollbarContent}\n` : '');
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// 兼容旧结构
|
|
163
|
+
const themeContent = await fs.readFile(themePath, 'utf-8');
|
|
164
|
+
const scaleContent = await fs.readFile(scalePath, 'utf-8');
|
|
165
|
+
const scrollbarContent = await readFileIfExists(scrollbarPath);
|
|
166
|
+
content =
|
|
167
|
+
`/* ${TOKEN_MARKER} - 在应用入口 import 此文件以驱动组件样式 */\n${scaleContent}\n\n${themeContent}\n` +
|
|
168
|
+
(scrollbarContent ? `\n${scrollbarContent}\n` : '');
|
|
169
|
+
}
|
|
170
|
+
await fs.ensureDir(path.dirname(targetPath));
|
|
171
|
+
await fs.writeFile(targetPath, content, 'utf-8');
|
|
172
|
+
return { path: targetPath, created: true };
|
|
173
|
+
}
|
|
174
|
+
export function getQoderTokensImportPath() {
|
|
175
|
+
return `./${DEDICATED_TOKENS_FILE}`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: 模板源码 + 用户 aliases
|
|
3
|
+
* [OUTPUT]: 替换别名后的源码字符串
|
|
4
|
+
* [POS]: cli/src/utils/transform.ts
|
|
5
|
+
*
|
|
6
|
+
* [PROTOCOL]: 文件逻辑变更时同步更新此 Header
|
|
7
|
+
*/
|
|
8
|
+
/** 移除文件开头的 L3 协议块注释,使按需引入到用户项目的代码不包含内部文档。 */
|
|
9
|
+
export function stripL3Header(source) {
|
|
10
|
+
return source.replace(/^\s*\/\*\*[\s\S]*?\*\/\s*/, '').trimStart();
|
|
11
|
+
}
|
|
12
|
+
export function transformImports(source, aliases) {
|
|
13
|
+
let result = source;
|
|
14
|
+
result = result.replace(/from ['"]@\/lib\/utils['"]/g, `from '${aliases.utils}'`);
|
|
15
|
+
if (aliases.components) {
|
|
16
|
+
result = result.replace(/from ['"]@\/components\//g, `from '${aliases.components}/`);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkdesign",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.10",
|
|
4
|
+
"description": "CLI for Spark Design - add components on demand (copy source to your project)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sparkdesign": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"registry",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
6
14
|
"scripts": {
|
|
7
|
-
"
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"cli": "node dist/index.js",
|
|
18
|
+
"prepublishOnly": "node ../scripts/sync-registry-to-cli.mjs"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.3.0",
|
|
22
|
+
"commander": "^12.1.0",
|
|
23
|
+
"diff": "^7.0.0",
|
|
24
|
+
"execa": "^9.5.2",
|
|
25
|
+
"fs-extra": "^11.2.0",
|
|
26
|
+
"ora": "^8.1.1",
|
|
27
|
+
"prompts": "^2.4.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/diff": "^6.0.0",
|
|
31
|
+
"@types/fs-extra": "^11.0.4",
|
|
32
|
+
"@types/node": "^22.10.1",
|
|
33
|
+
"@types/prompts": "^2.4.9",
|
|
34
|
+
"typescript": "~5.9.3"
|
|
8
35
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
"license": "MIT",
|
|
12
|
-
"repository": {
|
|
13
|
-
"type": "git",
|
|
14
|
-
"url": "https://github.com/cunyu666/sparkdesign.git"
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
15
38
|
}
|
|
16
39
|
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { Button } from '../../basic/button'
|
|
4
|
+
import { createRef } from 'react'
|
|
5
|
+
|
|
6
|
+
describe('Button', () => {
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 渲染测试
|
|
9
|
+
// ============================================================
|
|
10
|
+
describe('渲染', () => {
|
|
11
|
+
it('应正确渲染 children 文本', () => {
|
|
12
|
+
render(<Button>点击我</Button>)
|
|
13
|
+
expect(screen.getByRole('button')).toHaveTextContent('点击我')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('应渲染为原生 button 元素', () => {
|
|
17
|
+
render(<Button>按钮</Button>)
|
|
18
|
+
expect(screen.getByRole('button').tagName).toBe('BUTTON')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('应设置正确的 displayName', () => {
|
|
22
|
+
expect(Button.displayName).toBe('Button')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// Variant 变体测试
|
|
28
|
+
// ============================================================
|
|
29
|
+
describe('variant 变体', () => {
|
|
30
|
+
it('默认应使用 ghost 变体', () => {
|
|
31
|
+
render(<Button>Ghost</Button>)
|
|
32
|
+
expect(screen.getByRole('button')).toHaveClass('bg-transparent')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('variant="primary" 应应用主色样式', () => {
|
|
36
|
+
render(<Button variant="primary">Primary</Button>)
|
|
37
|
+
expect(screen.getByRole('button')).toHaveClass('bg-primary')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('variant="secondary" 应应用次级样式', () => {
|
|
41
|
+
render(<Button variant="secondary">Secondary</Button>)
|
|
42
|
+
expect(screen.getByRole('button')).toHaveClass('bg-bg-highlight')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('variant="tertiary" 应应用三级样式', () => {
|
|
46
|
+
render(<Button variant="tertiary">Tertiary</Button>)
|
|
47
|
+
expect(screen.getByRole('button')).toHaveClass('bg-fill-secondary')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('variant="text" 应应用纯文字样式', () => {
|
|
51
|
+
render(<Button variant="text">Text</Button>)
|
|
52
|
+
expect(screen.getByRole('button')).toHaveClass('bg-transparent')
|
|
53
|
+
expect(screen.getByRole('button')).toHaveClass('text-text-secondary')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// Size 尺寸测试
|
|
59
|
+
// ============================================================
|
|
60
|
+
describe('size 尺寸', () => {
|
|
61
|
+
it('默认应使用 md 尺寸', () => {
|
|
62
|
+
render(<Button>Medium</Button>)
|
|
63
|
+
expect(screen.getByRole('button')).toHaveClass('h-9')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('size="sm" 应应用小尺寸样式', () => {
|
|
67
|
+
render(<Button size="sm">Small</Button>)
|
|
68
|
+
expect(screen.getByRole('button')).toHaveClass('h-7')
|
|
69
|
+
expect(screen.getByRole('button')).toHaveClass('text-xs')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('size="md" 应应用中等尺寸样式', () => {
|
|
73
|
+
render(<Button size="md">Medium</Button>)
|
|
74
|
+
expect(screen.getByRole('button')).toHaveClass('h-9')
|
|
75
|
+
expect(screen.getByRole('button')).toHaveClass('text-sm')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('size="lg" 应应用大尺寸样式', () => {
|
|
79
|
+
render(<Button size="lg">Large</Button>)
|
|
80
|
+
expect(screen.getByRole('button')).toHaveClass('h-11')
|
|
81
|
+
expect(screen.getByRole('button')).toHaveClass('text-base')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// Rounded 圆角测试
|
|
87
|
+
// ============================================================
|
|
88
|
+
describe('rounded 圆角', () => {
|
|
89
|
+
it('默认应使用 square 圆角', () => {
|
|
90
|
+
render(<Button>Square</Button>)
|
|
91
|
+
expect(screen.getByRole('button')).toHaveClass('rounded')
|
|
92
|
+
expect(screen.getByRole('button')).not.toHaveClass('rounded-full')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('rounded="pill" 应应用全圆角样式', () => {
|
|
96
|
+
render(<Button rounded="pill">Pill</Button>)
|
|
97
|
+
expect(screen.getByRole('button')).toHaveClass('rounded-full')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ============================================================
|
|
102
|
+
// textButton 模式测试
|
|
103
|
+
// ============================================================
|
|
104
|
+
describe('textButton 模式', () => {
|
|
105
|
+
it('textButton=true 应移除高度和内边距', () => {
|
|
106
|
+
render(<Button textButton>Text Only</Button>)
|
|
107
|
+
const button = screen.getByRole('button')
|
|
108
|
+
expect(button).toHaveClass('h-auto')
|
|
109
|
+
expect(button).toHaveClass('px-0')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('textButton=true 应强制使用 text 变体样式', () => {
|
|
113
|
+
render(<Button textButton variant="primary">Text</Button>)
|
|
114
|
+
const button = screen.getByRole('button')
|
|
115
|
+
// 即使传入 primary,textButton 也会覆盖为 text 变体
|
|
116
|
+
expect(button).toHaveClass('bg-transparent')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('textButton 应保留正确的字体大小', () => {
|
|
120
|
+
const { rerender } = render(<Button textButton size="sm">SM</Button>)
|
|
121
|
+
expect(screen.getByRole('button')).toHaveClass('text-xs')
|
|
122
|
+
|
|
123
|
+
rerender(<Button textButton size="md">MD</Button>)
|
|
124
|
+
expect(screen.getByRole('button')).toHaveClass('text-sm')
|
|
125
|
+
|
|
126
|
+
rerender(<Button textButton size="lg">LG</Button>)
|
|
127
|
+
expect(screen.getByRole('button')).toHaveClass('text-base')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ============================================================
|
|
132
|
+
// 图标测试
|
|
133
|
+
// ============================================================
|
|
134
|
+
describe('图标', () => {
|
|
135
|
+
it('应渲染 prefixIcon', () => {
|
|
136
|
+
render(
|
|
137
|
+
<Button prefixIcon={<span data-testid="prefix">←</span>}>
|
|
138
|
+
有前置图标
|
|
139
|
+
</Button>
|
|
140
|
+
)
|
|
141
|
+
expect(screen.getByTestId('prefix')).toBeInTheDocument()
|
|
142
|
+
expect(screen.getByTestId('prefix')).toHaveTextContent('←')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('应渲染 suffixIcon', () => {
|
|
146
|
+
render(
|
|
147
|
+
<Button suffixIcon={<span data-testid="suffix">→</span>}>
|
|
148
|
+
有后置图标
|
|
149
|
+
</Button>
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByTestId('suffix')).toBeInTheDocument()
|
|
152
|
+
expect(screen.getByTestId('suffix')).toHaveTextContent('→')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('应同时渲染前后置图标', () => {
|
|
156
|
+
render(
|
|
157
|
+
<Button
|
|
158
|
+
prefixIcon={<span data-testid="prefix">←</span>}
|
|
159
|
+
suffixIcon={<span data-testid="suffix">→</span>}
|
|
160
|
+
>
|
|
161
|
+
双图标
|
|
162
|
+
</Button>
|
|
163
|
+
)
|
|
164
|
+
expect(screen.getByTestId('prefix')).toBeInTheDocument()
|
|
165
|
+
expect(screen.getByTestId('suffix')).toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('无图标时不应渲染图标包裹元素', () => {
|
|
169
|
+
const { container } = render(<Button>无图标</Button>)
|
|
170
|
+
// 检查没有图标包裹的 span 元素
|
|
171
|
+
const iconWrappers = container.querySelectorAll('button > span.inline-flex')
|
|
172
|
+
expect(iconWrappers).toHaveLength(0)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ============================================================
|
|
177
|
+
// 交互测试
|
|
178
|
+
// ============================================================
|
|
179
|
+
describe('交互', () => {
|
|
180
|
+
it('点击时应触发 onClick 回调', () => {
|
|
181
|
+
const handleClick = vi.fn()
|
|
182
|
+
render(<Button onClick={handleClick}>点击</Button>)
|
|
183
|
+
|
|
184
|
+
fireEvent.click(screen.getByRole('button'))
|
|
185
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('多次点击应触发相应次数的回调', () => {
|
|
189
|
+
const handleClick = vi.fn()
|
|
190
|
+
render(<Button onClick={handleClick}>点击</Button>)
|
|
191
|
+
|
|
192
|
+
const button = screen.getByRole('button')
|
|
193
|
+
fireEvent.click(button)
|
|
194
|
+
fireEvent.click(button)
|
|
195
|
+
fireEvent.click(button)
|
|
196
|
+
|
|
197
|
+
expect(handleClick).toHaveBeenCalledTimes(3)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ============================================================
|
|
202
|
+
// Disabled 状态测试
|
|
203
|
+
// ============================================================
|
|
204
|
+
describe('disabled 状态', () => {
|
|
205
|
+
it('disabled=true 应设置 disabled 属性', () => {
|
|
206
|
+
render(<Button disabled>禁用</Button>)
|
|
207
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('disabled 时应应用禁用样式', () => {
|
|
211
|
+
render(<Button disabled>禁用</Button>)
|
|
212
|
+
expect(screen.getByRole('button')).toHaveClass('disabled:opacity-50')
|
|
213
|
+
expect(screen.getByRole('button')).toHaveClass('disabled:cursor-not-allowed')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('disabled 时点击不应触发 onClick', () => {
|
|
217
|
+
const handleClick = vi.fn()
|
|
218
|
+
render(<Button disabled onClick={handleClick}>禁用</Button>)
|
|
219
|
+
|
|
220
|
+
fireEvent.click(screen.getByRole('button'))
|
|
221
|
+
expect(handleClick).not.toHaveBeenCalled()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('默认不应禁用', () => {
|
|
225
|
+
render(<Button>正常</Button>)
|
|
226
|
+
expect(screen.getByRole('button')).not.toBeDisabled()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ============================================================
|
|
231
|
+
// Ref 转发测试
|
|
232
|
+
// ============================================================
|
|
233
|
+
describe('ref 转发', () => {
|
|
234
|
+
it('应正确转发 ref 到 button 元素', () => {
|
|
235
|
+
const ref = createRef<HTMLButtonElement>()
|
|
236
|
+
render(<Button ref={ref}>带 Ref</Button>)
|
|
237
|
+
|
|
238
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('ref.current 应指向渲染的 button', () => {
|
|
242
|
+
const ref = createRef<HTMLButtonElement>()
|
|
243
|
+
render(<Button ref={ref}>带 Ref</Button>)
|
|
244
|
+
|
|
245
|
+
expect(ref.current).toBe(screen.getByRole('button'))
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('callback ref 应被正确调用', () => {
|
|
249
|
+
const callbackRef = vi.fn()
|
|
250
|
+
render(<Button ref={callbackRef}>Callback Ref</Button>)
|
|
251
|
+
|
|
252
|
+
expect(callbackRef).toHaveBeenCalled()
|
|
253
|
+
expect(callbackRef.mock.calls[0][0]).toBeInstanceOf(HTMLButtonElement)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ============================================================
|
|
258
|
+
// className 覆盖测试
|
|
259
|
+
// ============================================================
|
|
260
|
+
describe('className 覆盖', () => {
|
|
261
|
+
it('应合并自定义 className', () => {
|
|
262
|
+
render(<Button className="custom-class">自定义类</Button>)
|
|
263
|
+
expect(screen.getByRole('button')).toHaveClass('custom-class')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('自定义 className 应与默认类名共存', () => {
|
|
267
|
+
render(<Button className="my-custom" variant="primary">合并类</Button>)
|
|
268
|
+
const button = screen.getByRole('button')
|
|
269
|
+
expect(button).toHaveClass('my-custom')
|
|
270
|
+
expect(button).toHaveClass('bg-primary')
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// ============================================================
|
|
275
|
+
// 原生属性透传测试
|
|
276
|
+
// ============================================================
|
|
277
|
+
describe('原生属性透传', () => {
|
|
278
|
+
it('应透传 type 属性', () => {
|
|
279
|
+
render(<Button type="submit">提交</Button>)
|
|
280
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('应透传 aria-label 属性', () => {
|
|
284
|
+
render(<Button aria-label="关闭按钮">×</Button>)
|
|
285
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', '关闭按钮')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('应透传 data-* 属性', () => {
|
|
289
|
+
render(<Button data-testid="my-button">测试</Button>)
|
|
290
|
+
expect(screen.getByTestId('my-button')).toBeInTheDocument()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('应透传 id 属性', () => {
|
|
294
|
+
render(<Button id="unique-button">ID 按钮</Button>)
|
|
295
|
+
expect(screen.getByRole('button')).toHaveAttribute('id', 'unique-button')
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// ============================================================
|
|
300
|
+
// 组合场景测试
|
|
301
|
+
// ============================================================
|
|
302
|
+
describe('组合场景', () => {
|
|
303
|
+
it('应支持所有 props 组合', () => {
|
|
304
|
+
const handleClick = vi.fn()
|
|
305
|
+
render(
|
|
306
|
+
<Button
|
|
307
|
+
variant="primary"
|
|
308
|
+
size="lg"
|
|
309
|
+
rounded="pill"
|
|
310
|
+
prefixIcon={<span>🚀</span>}
|
|
311
|
+
suffixIcon={<span>✨</span>}
|
|
312
|
+
onClick={handleClick}
|
|
313
|
+
className="extra-class"
|
|
314
|
+
data-testid="combo-button"
|
|
315
|
+
>
|
|
316
|
+
组合按钮
|
|
317
|
+
</Button>
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const button = screen.getByTestId('combo-button')
|
|
321
|
+
expect(button).toHaveClass('bg-primary')
|
|
322
|
+
expect(button).toHaveClass('h-11')
|
|
323
|
+
expect(button).toHaveClass('rounded-full')
|
|
324
|
+
expect(button).toHaveClass('extra-class')
|
|
325
|
+
expect(button).toHaveTextContent('🚀')
|
|
326
|
+
expect(button).toHaveTextContent('组合按钮')
|
|
327
|
+
expect(button).toHaveTextContent('✨')
|
|
328
|
+
|
|
329
|
+
fireEvent.click(button)
|
|
330
|
+
expect(handleClick).toHaveBeenCalled()
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
})
|