组件库全文件解构

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
这是一个组件库项目,技术栈:Vue 3 + TypeScript + Vite,测试工具:Vitest + Storybook,打包工具:Rollup + Vue-Test-Utils,项目部署:VitePress站点生成 + GitHubPages页面托管 + github actions CI/CD 自动化部署

Veyra
├─ .nvmrc
├─ env.d.ts
├─ libs
│ ├─ README.md
│ ├─ vite-plugins
│ │ ├─ hooksPlugin.ts
│ │ ├─ index.ts
│ │ ├─ package.json
│ │ └─ vite.config.ts
│ └─ vitepress-preview-component
│ ├─ container
│ │ ├─ ant-design-ui
│ │ │ ├─ ant-design.scss
│ │ │ └─ AntDesign.vue
│ │ ├─ element-plus
│ │ │ ├─ element-plus.scss
│ │ │ └─ ElementPlus.vue
│ │ └─ naive-ui
│ │ ├─ naive-ui.scss
│ │ └─ NaiveUI.vue
│ ├─ hooks
│ │ ├─ use-codecopy.ts
│ │ ├─ use-codefold.ts
│ │ └─ use-namespaces.ts
│ ├─ icons
│ │ ├─ code-close.vue
│ │ ├─ code-copy.vue
│ │ ├─ code-open.vue
│ │ └─ copy-success.vue
│ ├─ index.ts
│ ├─ messages
│ │ ├─ index.ts
│ │ ├─ message-notice.scss
│ │ └─ message-notice.vue
│ ├─ package.json
│ ├─ styles
│ │ └─ various.scss
│ ├─ tsconfig.json
│ ├─ types.d.ts
│ └─ vite.config.ts
├─ package.json
├─ packages
│ ├─ components
│ │ ├─ Alert
│ │ │ ├─ Alert.test.tsx
│ │ │ ├─ Alert.vue
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Button
│ │ │ ├─ Button.test.tsx
│ │ │ ├─ Button.vue
│ │ │ ├─ ButtonGroup.vue
│ │ │ ├─ constants.ts
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Collapse
│ │ │ ├─ Collapse.test.tsx
│ │ │ ├─ Collapse.vue
│ │ │ ├─ CollapseItem.vue
│ │ │ ├─ constants.ts
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ ├─ transitionEvents.ts
│ │ │ └─ types.ts
│ │ ├─ ConfigProvider
│ │ │ ├─ ConfigProvider.vue
│ │ │ ├─ constants.ts
│ │ │ ├─ hooks.ts
│ │ │ ├─ index.ts
│ │ │ └─ types.ts
│ │ ├─ Dropdown
│ │ │ ├─ constants.ts
│ │ │ ├─ Dropdown.test.tsx
│ │ │ ├─ Dropdown.vue
│ │ │ ├─ DropdownItem.vue
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Form
│ │ │ ├─ constants.ts
│ │ │ ├─ Form.vue
│ │ │ ├─ FormItem.vue
│ │ │ ├─ hooks.ts
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Icon
│ │ │ ├─ Icon.vue
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ index.test.ts
│ │ ├─ index.ts
│ │ ├─ Input
│ │ │ ├─ index.ts
│ │ │ ├─ Input.test.tsx
│ │ │ ├─ Input.vue
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Loading
│ │ │ ├─ directive.ts
│ │ │ ├─ index.ts
│ │ │ ├─ Loading.test.tsx
│ │ │ ├─ Loading.vue
│ │ │ ├─ service.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Message
│ │ │ ├─ index.ts
│ │ │ ├─ Message.test.tsx
│ │ │ ├─ Message.vue
│ │ │ ├─ methods.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ MessageBox
│ │ │ ├─ index.ts
│ │ │ ├─ MessageBox.test.tsx
│ │ │ ├─ MessageBox.vue
│ │ │ ├─ methods.ts
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Notification
│ │ │ ├─ index.ts
│ │ │ ├─ methods.ts
│ │ │ ├─ Notification.test.tsx
│ │ │ ├─ Notification.vue
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Overlay
│ │ │ ├─ Overlay.vue
│ │ │ └─ types.ts
│ │ ├─ package.json
│ │ ├─ Popconfirm
│ │ │ ├─ index.ts
│ │ │ ├─ Popconfirm.test.tsx
│ │ │ ├─ Popconfirm.vue
│ │ │ ├─ style.css
│ │ │ └─ types.ts
│ │ ├─ Select
│ │ │ ├─ constants.ts
│ │ │ ├─ index.ts
│ │ │ ├─ Option.vue
│ │ │ ├─ Select.vue
│ │ │ ├─ style.css
│ │ │ ├─ types.ts
│ │ │ └─ useKeyMap.ts
│ │ ├─ Switch
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ ├─ Switch.test.tsx
│ │ │ ├─ Switch.vue
│ │ │ └─ types.ts
│ │ ├─ Tooltip
│ │ │ ├─ index.ts
│ │ │ ├─ style.css
│ │ │ ├─ Tooltip.test.tsx
│ │ │ ├─ Tooltip.vue
│ │ │ ├─ types.ts
│ │ │ └─ useEventsToTiggerNode.ts
│ │ └─ Upload
│ │ ├─ index.ts
│ │ ├─ types.ts
│ │ ├─ Upload.vue
│ │ └─ UploadList.vue
│ ├─ constants
│ │ ├─ index.ts
│ │ ├─ key.ts
│ │ └─ package.json
│ ├─ core
│ │ ├─ build
│ │ │ ├─ vite.es.config.ts
│ │ │ └─ vite.umd.config.ts
│ │ ├─ componens.ts
│ │ ├─ index.ts
│ │ ├─ makeInstaller.ts
│ │ ├─ package.json
│ │ └─ pringLogo.ts
│ ├─ docs
│ │ ├─ .vitepress
│ │ │ ├─ config.ts
│ │ │ └─ theme
│ │ │ └─ index.ts
│ │ ├─ components
│ │ │ ├─ alert.md
│ │ │ ├─ button.md
│ │ │ ├─ collapse.md
│ │ │ ├─ dropdown.md
│ │ │ ├─ form.md
│ │ │ ├─ loading.md
│ │ │ ├─ message.md
│ │ │ ├─ messagebox.md
│ │ │ ├─ notification.md
│ │ │ ├─ popconfirm.md
│ │ │ └─ tooltip.md
│ │ ├─ demo
│ │ │ ├─ alert
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Close.vue
│ │ │ │ ├─ Desc.vue
│ │ │ │ ├─ IconDesc.vue
│ │ │ │ ├─ ShowIcon.vue
│ │ │ │ ├─ TextCenter.vue
│ │ │ │ └─ Theme.vue
│ │ │ ├─ button
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Disabled.vue
│ │ │ │ ├─ Group.vue
│ │ │ │ ├─ Icon.vue
│ │ │ │ ├─ Loading.vue
│ │ │ │ ├─ Size.vue
│ │ │ │ ├─ Tag.vue
│ │ │ │ └─ Throttle.vue
│ │ │ ├─ collapse
│ │ │ │ ├─ Accordion.vue
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ CustomTitle.vue
│ │ │ │ └─ Disabled.vue
│ │ │ ├─ dropdown
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Command.vue
│ │ │ │ ├─ Disabled.vue
│ │ │ │ ├─ HideOnClick.vue
│ │ │ │ ├─ InstanceMethod.vue
│ │ │ │ ├─ Size.vue
│ │ │ │ ├─ SplitButton.vue
│ │ │ │ └─ Trigger.vue
│ │ │ ├─ form
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ CustomValidate.vue
│ │ │ │ ├─ Position.vue
│ │ │ │ └─ Validate.vue
│ │ │ ├─ loading
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Custom.vue
│ │ │ │ └─ Fullscreen.vue
│ │ │ ├─ message
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Center.vue
│ │ │ │ ├─ Closeable.vue
│ │ │ │ ├─ Test.vue
│ │ │ │ └─ Type.vue
│ │ │ ├─ messagebox
│ │ │ │ ├─ Alert.vue
│ │ │ │ ├─ Center.vue
│ │ │ │ ├─ Confirm.vue
│ │ │ │ ├─ Custom.vue
│ │ │ │ ├─ Prompt.vue
│ │ │ │ └─ VNode.vue
│ │ │ ├─ notification
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Closeable.vue
│ │ │ │ └─ Type.vue
│ │ │ ├─ popconfirm
│ │ │ │ ├─ Basic.vue
│ │ │ │ ├─ Callback.vue
│ │ │ │ └─ Custom.vue
│ │ │ └─ tooltip
│ │ │ ├─ Basic.vue
│ │ │ ├─ Disabled.vue
│ │ │ └─ Slot.vue
│ │ ├─ get-started.md
│ │ ├─ index.md
│ │ └─ package.json
│ ├─ hooks
│ │ ├─ index.ts
│ │ ├─ package.json
│ │ ├─ useClickOutside.ts
│ │ ├─ useDisabledStyle.ts
│ │ ├─ useEventListener.ts
│ │ ├─ useFocusController.ts
│ │ ├─ useId.ts
│ │ ├─ useLocale.ts
│ │ ├─ useOffset.ts
│ │ ├─ useProp.ts
│ │ ├─ useZIndex.ts
│ │ ├─ vite.config.ts
│ │ └─ __test__
│ │ ├─ index.test.tsx
│ │ ├─ useClickOutset.test.tsx
│ │ └─ useEventListener.test.tsx
│ ├─ locale
│ │ ├─ index.ts
│ │ ├─ lang
│ │ │ ├─ en.ts
│ │ │ ├─ ja.ts
│ │ │ ├─ ko.ts
│ │ │ ├─ zh-cn.ts
│ │ │ └─ zh-tw.ts
│ │ └─ package.json
│ ├─ play
│ │ ├─ .storybook
│ │ │ ├─ main.js
│ │ │ └─ preview.js
│ │ ├─ index.html
│ │ ├─ package.json
│ │ ├─ public
│ │ │ └─ vite.svg
│ │ ├─ README.md
│ │ ├─ src
│ │ │ ├─ App.vue
│ │ │ ├─ main.ts
│ │ │ ├─ stories
│ │ │ │ ├─ Alert.stories.tsx
│ │ │ │ ├─ Button.stories.ts
│ │ │ │ └─ Collapse.stories.ts
│ │ │ └─ vite-env.d.ts
│ │ └─ vite.config.ts
│ ├─ README.md
│ ├─ theme
│ │ ├─ index.css
│ │ ├─ package.json
│ │ └─ reset.css
│ └─ utils
│ ├─ error.ts
│ ├─ index.ts
│ ├─ install.ts
│ ├─ package.json
│ ├─ style.ts
│ └─ __tests__
│ ├─ error.test.tsx
│ ├─ index.test.tsx
│ └─ install.test.tsx
├─ pnpm-lock.yaml
├─ pnpm-workspace.yaml
├─ postcss.config.cjs
├─ tsconfig.build.json
├─ tsconfig.json
├─ tsconfig.node.json
├─ vitest.config.ts
└─ vitest.setup.ts

项目解构

1
2
ctrl+i
alt+shift+5

├─ .nvmrc

1
v18.17.0

控制nodejs版本的

├─ env.d.ts

用来区分不同的运行环境:生产(production)、开发(development)和测试(test)

1
2
3
declare const PROD: boolean; //生产
declare const DEV: boolean; //开发
declare const TEST: boolean; //测试

在项目的构建脚本或配置文件中,会依据这些变量来决定是否启用某些调试工具、日志记录或者优化措施。

  1. 生产环境 (Production):
    • 情景: 当你构建并部署你的应用到生产服务器上时(例如通过 CI/CD 流程自动部署到 GitHub Pages 或者其他任何生产环境)。假设你在 Vite 配置文件中为生产环境设置了 PROD=true
    • 操作: 执行构建命令如 npm run build 后,再将生成的静态资源部署到生产环境。
    • 结果: 在启动或加载应用程序的过程中,如果该函数被执行,那么将会看到那个带有渐变色效果的ASCII艺术logo出现在服务器日志或者浏览器控制台中。
  2. 开发模式 (Development):
    • 情景: 当你在本地开发环境中运行你的Vue 3 + TypeScript项目时,比如使用 npm run serve 或者类似的命令启动一个热重载的开发服务器。
    • 操作: 直接在终端执行 npm run serve 或者配置好的开发模式启动脚本。
    • 结果: 在控制台中你会看到简单的提示消息 [EricUI]:dev mode...,这表明现在正处于开发模式下,并且可以快速识别出当前的工作状态。
  3. 测试环境 (Testing):
    • 情景: 运行单元测试或集成测试时(例如使用 Vitest),通常不会设置 PRODDEV 变量为 true。因此,在这种情况下,既不满足生产环境也不满足开发模式的条件。
    • 操作: 执行测试命令如 npm testvitest
    • 结果: 控制台上不会显示上述的ASCII艺术logo或开发模式的消息,以避免干扰测试输出。

├─ libs

辅助库&工具

├─ vite-plugins

自定义vite插件

├─ hooksPlugin.ts

├─ index.ts

├─ package.json

└─ vite.config.ts

└─ vitepress-preview-component

vitepress组件预览插件

├─ container

│ │ ├─ ant-design-ui
│ │ │ ├─ ant-design.scss
│ │ │ └─ AntDesign.vue
│ │ ├─ element-plus
│ │ │ ├─ element-plus.scss
│ │ │ └─ ElementPlus.vue
│ │ └─ naive-ui
│ │ ├─ naive-ui.scss
│ │ └─ NaiveUI.vue

│ ├─ hooks
│ │ ├─ use-codecopy.ts
│ │ ├─ use-codefold.ts
│ │ └─ use-namespaces.ts

│ ├─ icons
│ │ ├─ code-close.vue
│ │ ├─ code-copy.vue
│ │ ├─ code-open.vue
│ │ └─ copy-success.vue

│ ├─ index.ts

│ ├─ messages
│ │ ├─ index.ts
│ │ ├─ message-notice.scss
│ │ └─ message-notice.vue

│ ├─ package.json
│ ├─ styles
│ │ └─ various.scss

│ ├─ tsconfig.json

│ ├─ types.d.ts

│ └─ vite.config.ts

├─ packages

项目分包

├─ components

所有的组件逻辑

├─ Alert

提示

–API–

Name Description Type Default
title Alert 标题 string
type Alert 类型 enum - 'success'| 'warning'| 'danger'| 'info' info
description 描述性文本 string
closable 是否可以关闭 boolean true
center 文字是否居中 boolean false
show-icon 是否展示图标 boolean false
effect 主题样式 enum - 'light'| 'dark'\ light
Name Description Type
close 关闭 Alert 时触发的事件 (event: MouseEvent)=> void
Name Description
default 默认插槽,用于设置 Alert 的内容描述
title 标题的内容
Name Description Type
open 打开 Alert () => void
close 关闭 Alert () => void

├─ Alert.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { describe, it, expect, vi } from "vitest";
import type { AlertType } from "./types";
import { mount } from "@vue/test-utils";
import Alert from "./Alert.vue";
import Icon from "../Icon/Icon.vue";

describe("Alert.vue", () => {
const title = "Test Alert";
const desc = "This is a test description";
it("should render the alert with default props", () => {
const wrapper = mount(Alert, {
props: {
title,
},
slots: {
default: desc,
},
global: {
stubs: ["ErIcon"],
},
});
expect(wrapper.text()).toContain(title);
expect(wrapper.text()).toContain(desc);

// close icon
const iconElement = wrapper.findComponent(Icon);
expect(iconElement.exists()).toBeTruthy();
expect(iconElement.attributes("icon")).toBe("xmark");

const wrapper2 = mount(() => (
<Alert title={title} description={desc}></Alert>
));

expect(wrapper2.text()).toContain(title);
expect(wrapper2.text()).toContain(desc);
});

it.each([
["info", "circle-info"],
["success", "check-circle"],
["warning", "circle-exclamation"],
["danger", "circle-xmark"],
["error", "circle-xmark"],
["non-compliance", "circle-info"], // 不符合 type 定义的
])("should has the correct icon when props type is %s", (type, iconName) => {
const wrapper = mount(Alert, {
props: {
title,
closable: false,
showIcon: true,
type: type as AlertType,
},
slots: {
default: desc,
},
global: {
stubs: ["ErIcon"],
},
});

const iconElement = wrapper.findComponent(Icon);
expect(iconElement.exists()).toBeTruthy();
expect(iconElement.attributes("icon")).toBe(iconName);
});

it("should emit close event when close icon is clicked", () => {
const onClose = vi.fn();

const wrapper = mount(Alert, {
props: {
title,
closable: true,
showIcon: false,
onClose,
},
slots: {
default: desc,
},
global: {
stubs: ["ErIcon"],
},
});
wrapper.findComponent(Icon).trigger("click");
expect(onClose).toHaveBeenCalled();
});

it("should allow custom content via slots", () => {
const wrapper = mount(Alert, {
props: {
title: "test title",
},
slots: {
default: desc,
title,
},
});
expect(wrapper.text()).toContain(desc);
expect(wrapper.text()).toContain(title);
expect(wrapper.text()).not.toContain("test title");
});

it("should support centering text", () => {
const wrapper = mount(Alert, {
props: {
title,
description: desc,
center: true,
},
});
//class
const rootNode = wrapper.find(".er-alert");
expect(rootNode.classes()).toContain("text-center");
});

it("should not render close icon when closable is false", () => {
const wrapper = mount(Alert, {
props: { closable: false },
});
expect(wrapper.find(".er-alert__close").exists()).toBe(false);
});

it("should toggle visibility when open and close methods are called", async () => {
const wrapper = mount(Alert, {
props: { title, closable: false },
});
await wrapper.vm.close();
expect(wrapper.find(".er-alert").attributes().style).toBe("display: none;");
await wrapper.vm.open();
expect(wrapper.find(".er-alert").attributes().style).toBe("");
});
});

├─ Alert.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<script setup lang="ts">
import type { AlertProps, AlertEmits, AlertInstance } from "./types";
import { ref, useSlots, computed } from "vue";
import { typeIconMap } from "@eric-ui/utils";
import ErIcon from "../Icon/Icon.vue";

defineOptions({
name: "ErAlert",
});
const props = withDefaults(defineProps<AlertProps>(), {
effect: "light",
type: "info",
closable: true,
});
const emits = defineEmits<AlertEmits>();
const slots = useSlots();

const visible = ref(true);

const iconName = computed(() => typeIconMap.get(props.type) ?? "circle-info");
const withDescription = computed(() => props.description || slots.default);

function close() {
visible.value = false;
emits("close");
}

function open() {
visible.value = true;
}

defineExpose<AlertInstance>({
open,
close,
});
</script>

<template>
<transition name="er-alert-fade">
<div
v-show="visible"
class="er-alert"
role="alert"
:class="{
[`er-alert__${type}`]: type,
[`er-alert__${effect}`]: effect,
'text-center': center,
}"
>
<er-icon
v-if="showIcon"
class="er-alert__icon"
:class="{ 'big-icon': withDescription }"
:icon="iconName"
/>
<div class="er-alert__content">
<span
class="er-alert__title"
:class="{ 'with-desc': withDescription }"
:style="{ display: center && !showIcon ? 'flow' : 'inline' }"
>
<slot name="title">{{ title }}</slot>
</span>
<p class="er-alert__description">
<slot>{{ description }}</slot>
</p>
<div class="er-alert__close" v-if="closable">
<er-icon @click.stop="close" icon="xmark" />
</div>
</div>
</div>
</transition>
</template>

<style scoped>
@import "./style.css";
</style>

├─ index.ts

1
2
3
4
5
6
import Alert from "./Alert.vue";
import { withInstall } from "@eric-ui/utils";

export const ErAlert = withInstall(Alert);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
.er-alert {
--er-alert-padding: 8px 16px;
--er-alert-border-radius-base: var(--er-border-radius-base);
--er-alert-bg-color: var(--er-color-white);
--er-alert-title-font-size: 14px;
--er-alert-title-font-size-with-desc: 16px;
--er-alert-desc-font-size: 14px;
--er-alert-text-color: var(--er-text-color-primary);
--er-alert-top-margin: 20px;
--er-alert-icon-size: 16px;
--er-alert-icon-margin: 8px;
--er-alert-big-icon-size: 28px;
--er-alert-big-icon-margin: 12px;
}
.er-alert {
padding: var(--er-alert-padding);
margin: 0;
box-sizing: border-box;
border-radius: var(--er-alert-border-radius-base);
position: relative;
background-color: var(--er-alert-bg-color);
overflow: hidden;
opacity: 1;
display: flex;
align-items: center;
transition: opacity var(--er-transition-duration);
margin-top: var(--er-alert-top-margin);
.er-alert__icon {
color: var(--er-alert-text-color);
font-size: var(--er-alert-icon-size);
width: var(--er-alert-icon-size);
margin-right: var(--er-alert-icon-margin);
&.big-icon{
font-size: var(--er-alert-big-icon-size);
width: var(--er-alert-big-icon-size);
margin-right: var(--er-alert-big-icon-margin);
}
}
.er-alert__content {
color: var(--er-alert-text-color);
vertical-align: text-top;
.er-alert__title{
font-size: var(--er-alert-title-font-size);
line-height: 24px;
&.with-desc {
font-size: var(--er-alert-title-font-size-with-desc);
}
}
.er-alert__description{
font-size: var(--er-alert-desc-font-size);
margin: 0;
}

.er-alert__close {
font-size: var(--er-alert-close-font-size);
opacity: 1;
position: absolute;
top: 12px;
right: 15px;
cursor: pointer;
:deep(.er-icon) {
vertical-align: top;
}
}
&.er-alert__light {
.er-alert__close {
color: var(--er-text-color-placeholder);
}
}
&.er-alert__dark {
.er-alert__close {
color: var(--er-color-white);
}
}
}


&.text-center {
justify-content: center;
span,p {
text-align: center;
}
}
}

.er-alert-fade-enter-from,
.er-alert-fade-leave-to {
opacity: 0;
}

@each $val in success,warning,info,danger {
.er-alert__$(val).er-alert__light {
--er-alert-text-color: var(--er-color-$(val));
--er-alert-bg-color: var(--er-color-$(val)-light-9);
}
.er-alert__$(val).er-alert__dark {
--er-alert-text-color: var(--er-color-white);
--er-alert-bg-color: var(--er-color-$(val));
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export type AlertType = "success" | "info" | "warning" | "danger";

export interface AlertProps {
title?: string;
type?: AlertType;
description?: string;
effect?: "light" | "dark";
closable?: boolean;
center?: boolean;
showIcon?: boolean;
}

export interface AlertEmits {
(e: "close"): void;
}

export interface AlertInstance {
open(): void;
close(): void;
}

├─ Button

–API–

├─ Button.test.tsx

单组件测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { describe, it, expect } from 'vitest' //vitest 是测试框架,提供 describe(定义测试套件)、it(定义单个测试用例)、expect(断言工具)
import { mount } from '@vue/test-utils' //Vue 的测试工具库,mount 函数用于挂载 Vue 组件以便测试
import Button from './Button.vue' //引入需要测试的 Button.vue 组件
import type { ButtonProps } from './types' //引入组件的 props 类型定义,确保 props 的类型正确

describe('Button.vue', () => {
// type 属性的类名测试
it('type 属性被设置时,组件添加了对应的类名(如 er-button--primary)', () => {
const types = ['primary', 'success', 'warning', 'danger', 'info'] //包含所有可能的 type 值
types.forEach((type) => {
//遍历每个type类型
const wrapper = mount(Button, {
//通过 mount 挂载 Button 组件,并传递当前 type 值作为 props
props: { type: type as ButtonProps['type'] },
})
expect(wrapper.classes()).toContain(`er-button--${type}`) //断言类名是否包含 er-button--${type}
})
})

// size 属性的类名测试
it('size 属性被设置时,组件添加了对应的类名(如 er-button--large)', () => {
const sizes = ['large', 'default', 'small']
sizes.forEach((size) => {
const wrapper = mount(Button, {
props: { size: size as ButtonProps['size'] },
})
expect(wrapper.classes()).toContain(`er-button--${size}`)
})
})

// plain、round 等布尔属性的类名测试
it.each([
//一个参数化测试,通过数组中的每一项([prop, className])生成多个测试用例
['plain', 'is-plain'],
['round', 'is-round'],
['circle', 'is-circle'],
['disabled', 'is-disabled'],
['loading', 'is-loading'],
])(
'当 prop(如 plain)被设置为 true 时,组件添加了对应的类名(如 is-plain)',
(prop, className) => {
const wrapper = mount(Button, {
//挂载组件
props: { [prop]: true }, //设置 props[prop] = true
})
expect(wrapper.classes()).toContain(className) //断言类名是否包含 className
}
)

// nativeType 属性的 type 属性测试
it('当 tag 是 button 时,组件的 type 属性等于 nativeType(如 submit)', () => {
const wrapper = mount(Button, {
props: { tag: 'button', nativeType: 'submit' }, //设置 tag 为 button,nativeType 为 submit
})
expect(wrapper.element.tagName).toBe('BUTTON') //标签名是否为 BUTTON
expect(wrapper.element.type).toBe('submit') //type 属性是否为 submit
})

// tag 属性的标签名测试
it('当 tag 被设置为 a 时,组件的根元素为 <a> 标签', () => {
const wrapper = mount(Button, {
props: { tag: 'a' }, //挂载组件并设置 tag 为 a
})
expect(wrapper.element.tagName.toLowerCase()).toBe('a') //断言标签名是否为 a(小写)
})

// click 事件测试
it('点击按钮时触发 click 事件', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click') //使用 trigger('click') 模拟点击事件(需 await 确保异步完成)
expect(wrapper.emitted().click).toHaveLength(1) //断言 emitted().click 是否有 1 次触发
})
})

├─ Button.vue

组件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<script setup lang="ts">
import { ref, computed, inject } from "vue";//响应式,计算,上下文依赖项注入
import type { ButtonProps, ButtonEmits, ButtonInstance } from "./types";
import { BUTTON_GROUP_CTX_KEY } from "./constants";//标识按钮组上下文
import { throttle } from "lodash-es";//节流事件处理
import TyIcon from "../Icon/Icon.vue";//自定义的图标组件TyIcon
defineOptions({//定义组件的名称为 ErButton
name: "ErButton",
});
const props = withDefaults(defineProps<ButtonProps>(), {//定义组件的props+设置默认值
tag: "button",//HTML 标签名
nativeType: "button",//原生按钮类型
useThrottle: true,//启用节流
throttleDuration: 500,//节流持续时间
});
const emits = defineEmits<ButtonEmits>();//事件发射器
const slots = defineSlots();//插槽,用于动态渲染内容
const buttonGroupCtx = inject(BUTTON_GROUP_CTX_KEY, void 0);//注入按钮组上下文(如果存在),用于继承按钮组的配置(如 size、type 等)

const _ref = ref<HTMLButtonElement>();
//计算属性 size,优先使用按钮组上下文中的 size,其次使用 props.size,最后为空字符串
const size = computed(() => buttonGroupCtx?.size ?? props.size ?? "");
//计算属性 type,优先使用按钮组上下文中的 type,其次使用 props.type,最后为空字符串
const type = computed(() => buttonGroupCtx?.type ?? props.type ?? "");
//定义计算属性 disabled,优先使用 props.disabled,其次使用按钮组上下文中的 disabled,最后为 false
const disabled = computed(() => props.disabled || buttonGroupCtx?.disabled || false);
//计算属性 iconStyle,当按钮有默认插槽内容时,图标右侧添加 6px 的间距,否则不添加间距
const iconStyle = computed(() => ({marginRight: slots.default ? "6px" : "0px",}));

//触发 click 事件并将事件对象传递给父组件
const handleBtnClick = (e: MouseEvent) => {
emits("click", e);
};
//使用 throttle 对 handleBtnClick 进行节流,限制点击事件的触发频率
const handlBtneCLickThrottle = throttle(handleBtnClick, props.throttleDuration);

//暴露组件的内部状态和方法,供父组件通过 ref 访问
defineExpose<ButtonInstance>({
ref: _ref,
disabled,
size,
type,
});
</script>

<template>
<component
:is="tag"//动态决定组件的根标签类型(button/a)
ref="_ref"//为组件实例绑定一个引用`ref`(可通过 `this.$refs._ref` 获取到该按钮的 DOM 元素)
class="er-button"
:class="{//使用对象语法动态绑定类名
[`er-button--${type}`]: type, //当 `type` 存在时(如 `primary`、`success`),添加类名 `er-button--primary`
[`er-button--${size}`]: size, //当 `size` 存在时(如 `large`、`small`),添加类名 `er-button--large`
'is-plain': plain, //当 plain 为 true 时,添加类名 `is-plain`
'is-round': round, //当 round 为 true 时,添加类名 `is-round`
'is-circle': circle,
'is-disabled': disabled,
'is-loading': loading,
}"
:disabled="disabled || loading ? true : void 0" //`disabled` 为 `true`,则渲染为 `<button disabled>`按钮被禁用
:type="tag === 'button' ? nativeType : void 0"
:autofocus="autofocus" //当 `autofocus` 为 `true` 时,按钮自动获得焦点
@click="
(e: MouseEvent) =>
useThrottle ? handlBtneCLickThrottle(e) : handleBtnClick(e)
" //当 `useThrottle` 为 `true` 时,调用防抖的 `handlBtneCLickThrottle` 方法;否则调用普通点击事件 `handleBtnClick`
>
<template v-if="loading">//条件插槽
<slot name="loading">//默认 || 接收用户传来的<er-icon>标签
<er-icon//默认提供spinner加载图标
class="loading-icon"
:icon="loadingIcon ?? 'spinner'"//不是库中的Icon就默认图标为spinner
:style="iconStyle"//默认style,用户可改
size="1x"//锁定的大小
spin//
/>
</slot>
</template>
<er-icon
:icon="icon"
size="1x"
:style="iconStyle"
v-if="icon && !loading"
/>
<slot></slot>
</component>
</template>

<style scoped>
@import "./style.css";
</style>

├─ ButtonGroup.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts">
import type { ButtonGroupProps } from "./types";
import { provide, reactive, toRef } from "vue";
import { BUTTON_GROUP_CTX_KEY } from "./constants";

defineOptions({
name: "ErButtonGroup",
});
const props = defineProps<ButtonGroupProps>();

provide(
BUTTON_GROUP_CTX_KEY,
reactive({
size: toRef(props, "size"),
type: toRef(props, "type"),
disabled: toRef(props, "disabled"),
})
);
</script>

<template>
<div class="er-button-group">
<slot></slot>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ constants.ts

1
2
3
4
5
6
7
8
import type { InjectionKey } from "vue";
import type { ButtonGroupContext } from "./types";

//注入键(Injection Key)(BUTTON_GROUP_CTX_KEY)
//主要作用是为按钮组组件(`ButtonGroup`)提供上下文(context),使得按钮组中的子按钮可以访问父级按钮组的相关信息。
export const BUTTON_GROUP_CTX_KEY:
InjectionKey<ButtonGroupContext> =
Symbol("buttonGroupContext");//原始数据类型,用于创建唯一的标识符

举个例子

1
2
3
4
5
6
7
8
9
10
11
//父组件
//会使用 `provide` 方法将某些数据或方法提供给子组件
import { provide } from "vue";
import { BUTTON_GROUP_CTX_KEY } from "./constants";
export default {
setup() {
const buttonGroupContext = { size: "large", type: "primary", };
provide(BUTTON_GROUP_CTX_KEY, buttonGroupContext); // 提供上下文
return {};
},
};
1
2
3
4
5
6
7
8
9
10
11
//子组件
//可以通过 `inject` 方法获取父组件提供的上下文
import { inject } from "vue";
import { BUTTON_GROUP_CTX_KEY } from "./constants";
export default {
setup() {
const buttonGroupContext = inject(BUTTON_GROUP_CTX_KEY); // 注入上下文
console.log(buttonGroupContext); // 输出 { size: "large", type: "primary" }
return {};
},
};

├─ index.ts

withInstall 方法将组件注册为全局组件

1
2
3
4
5
6
7
8
import Button from "./Button.vue";
import ButtonGroup from "./ButtonGroup.vue";
import { withInstall } from "@tyche/utils";

export const ErButton = withInstall(Button);
export const ErButtonGroup = withInstall(ButtonGroup);

export * from "./types";

├─ style.css

1

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { type Component, type ComputedRef, type Ref } from 'vue'
export type ButtonType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
export type NativeType = 'button' | 'submit' | 'reset'
export type ButtonSize = 'default' | 'large' | 'small'

export interface ButtonProps {
tag?: string | Component
type?: ButtonType
size?: ButtonSize
plain?: boolean
round?: boolean
circle?: boolean
disabled?: boolean
autofocus?: boolean
nativeType?: NativeType
icon?: string
loading?: boolean
loadingIcon?: string
useThrottle?: boolean
throttleDuration?: number
}

export interface ButtonGroupProps {
size?: ButtonSize
type?: ButtonType
disabled?: boolean
}

export interface ButtonGroupContext {
size?: ButtonSize
type?: ButtonType
disabled?: boolean
}

export interface ButtonEmits {
(e: 'click', value: MouseEvent): void
}

export interface ButtonInstance {
ref: Ref<HTMLButtonElement | void>
disabled: ComputedRef<boolean>
size: ComputedRef<string>
type: ComputedRef<string>
}

├─ Collapse

折叠面板

–API–

├─ Collapse.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import { beforeAll, describe, expect, test, vi } from "vitest";
import { DOMWrapper, mount, type VueWrapper } from "@vue/test-utils";
import transitionEvents from "./transitionEvents";

import Collapse from "./Collapse.vue";
import CollapseItem from "./CollapseItem.vue";

const onChange = vi.fn();

let wrapper: VueWrapper,
headers: DOMWrapper<Element>[],
contents: DOMWrapper<Element>[];

let firstHeader: DOMWrapper<Element>,
secondHeader: DOMWrapper<Element>,
disabledHeader: DOMWrapper<Element>,
firstContent: DOMWrapper<Element>,
secondContent: DOMWrapper<Element>,
disabledContent: DOMWrapper<Element>;

describe("Collapse.vue", () => {
beforeAll(() => {
wrapper = mount(
() => (
<Collapse modelValue={["a"]} {...{ onChange }}>
<CollapseItem name="a" title="title a">
content a
</CollapseItem>
<CollapseItem name="b" title="title b">
content b
</CollapseItem>
<CollapseItem name="c" title="title c" disabled>
content c
</CollapseItem>
</Collapse>
),
{
global: {
stubs: ["ErIcon"],
},
attachTo: document.body, // 最新版本 jsdom 更新缓存 bug
}
);

headers = wrapper.findAll(".er-collapse-item__header");
contents = wrapper.findAll(".er-collapse-item__wapper");

firstHeader = headers[0];
secondHeader = headers[1];
disabledHeader = headers[2];

firstContent = contents[0];
secondContent = contents[1];
disabledContent = contents[2];
});

test("测试基础结构以及对应文本", () => {
// lenght
expect(headers.length).toBe(3);
expect(contents.length).toBe(3);

// title
expect(firstHeader.text()).toBe("title a");

// content
expect(firstHeader.classes()).toContain("is-active");
expect(firstContent.isVisible()).toBeTruthy();
expect(secondHeader.classes()).not.toContain("is-active");
expect(secondContent.isVisible()).toBeFalsy();
expect(firstContent.text()).toBe("content a");
expect(secondContent.text()).toBe("content b");
});

test("点击标题展开/关闭内容", async () => {
// events
await firstHeader.trigger("click");
expect(firstContent.isVisible()).toBeFalsy();
await secondHeader.trigger("click");
expect(secondHeader.classes()).toContain("is-active");
expect(secondHeader.isVisible()).toBeTruthy();
expect(firstHeader.classes()).not.toContain("is-active");
expect(firstContent.isVisible()).toBeFalsy();
});

test("发送正确的事件", () => {
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith([]);
expect(onChange).toHaveBeenLastCalledWith(["b"]);
});

test("disabled 内容", async () => {
// disabled
expect(disabledHeader.classes()).toContain("is-disabled");
onChange.mockClear();
await disabledHeader.trigger("click");
expect(disabledContent.isVisible()).toBeFalsy();
expect(onChange).not.toHaveBeenCalled();
});

test("modelValue 变更", async () => {
wrapper.setValue(["b"], "modelValue");
await wrapper.vm.$nextTick();
expect(secondHeader.classes()).toContain("is-active");
expect(firstHeader.classes()).not.toContain("is-active");
});

test("手风琴模式", async () => {
wrapper = mount(
() => (
<Collapse accordion modelValue={["a"]} {...{ onChange }}>
<CollapseItem name="a" title="title a">
content a
</CollapseItem>
<CollapseItem name="b" title="title b">
content b
</CollapseItem>
</Collapse>
),
{
global: {
stubs: ["ErIcon"],
},
attachTo: document.body,
}
);

headers = wrapper.findAll(".er-collapse-item__header");
contents = wrapper.findAll(".er-collapse-item__wapper");

firstHeader = headers[0];
secondHeader = headers[1];

firstContent = contents[0];
secondContent = contents[1];
await firstHeader.trigger("click");
await secondHeader.trigger("click");
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith(["b"]);
expect(firstHeader.classes()).not.toContain("is-active");
expect(secondHeader.classes()).toContain("is-active");
});

test("手风琴模式 错误处理", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mount(
() => (
<Collapse accordion modelValue={["a", "b"]} {...{ onChange }}>
<CollapseItem name="a" title="title a">
content a
</CollapseItem>
<CollapseItem name="b" title="title b">
content b
</CollapseItem>
<CollapseItem name="c" title="title c" disabled>
content c
</CollapseItem>
</Collapse>
),
{
global: {
stubs: ["ErIcon"],
},
}
);
expect(warn.mock.calls).toMatchInlineSnapshot(
`
[
[
[ErUIError: [ErCollapse] accordion mode should only have one active item],
],
]
`
);
});
});

describe("Collapse/transitionEvents.ts", () => {
const wrapper = mount(() => <div></div>);
test("beforeEnter", () => {
transitionEvents.beforeEnter(wrapper.element);
expect(wrapper.element.style.height).toBe("0px");
expect(wrapper.element.style.overflow).toBe("hidden");
});
test("enter", () => {
transitionEvents.enter(wrapper.element);
expect(wrapper.element.style.height).toBe(
`${wrapper.element.scrollHeight}px`
);
});
test("afterEnter", () => {
transitionEvents.afterEnter(wrapper.element);
expect(wrapper.element.style.height).toBe("");
expect(wrapper.element.style.overflow).toBe("");
});
test("beforeLeave", () => {
transitionEvents.beforeLeave(wrapper.element);
expect(wrapper.element.style.height).toBe(
`${wrapper.element.scrollHeight}px`
);
expect(wrapper.element.style.overflow).toBe("hidden");
});
test("leave", () => {
transitionEvents.leave(wrapper.element);
expect(wrapper.element.style.height).toBe("0px");
});
test("afterLeave", () => {
transitionEvents.afterLeave(wrapper.element);
expect(wrapper.element.style.height).toBe("");
expect(wrapper.element.style.overflow).toBe("");
});
});

├─ Collapse.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<script setup lang="ts">
import { ref, provide, watch } from "vue";
import { each } from "lodash-es";
import type {
CollapseItemName,
CollapseProps,
CollapseEmits,
} from "./types";
import { debugWarn } from "@eric-ui/utils";
import { COLLAPSE_CTX_KEY } from "./constants";

const COMPONENT_NAME = "ErCollapse" as const;

defineOptions({
name: COMPONENT_NAME,
});

const props = defineProps<CollapseProps>();
const emits = defineEmits<CollapseEmits>();
const activeNames = ref<CollapseItemName[]>(props.modelValue);

if (props.accordion && activeNames.value.length > 1) {
debugWarn(COMPONENT_NAME, "accordion mode should only have one active item");
}

function handleItemClick(item: CollapseItemName) {
let _activeNames = [...activeNames.value];
// 手风琴模式
if (props.accordion) {
_activeNames = [_activeNames[0] === item ? "" : item];
updateActiveNames(_activeNames);
return;
}

const index = _activeNames.indexOf(item);
if (index > -1) {
// 存在,删除数组中的一项
_activeNames.splice(index, 1);
} else {
// 不存在,插入对应 name
_activeNames.push(item);
}
updateActiveNames(_activeNames);
}

function updateActiveNames(val: CollapseItemName[]) {
activeNames.value = val;
each(["update:modelValue", "change"], (e) =>
emits(e as "update:modelValue" & "change", val)
);
}

watch(
() => props.modelValue,
(val) => updateActiveNames(val)
);

provide(COLLAPSE_CTX_KEY, {
activeNames,
handleItemClick,
});
</script>

<template>
<div class="er-collapse">
<slot></slot>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ CollapseItem.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<script setup lang="ts">
import { type CollapseItemProps } from "./types";
import { inject, computed } from "vue";
import { COLLAPSE_CTX_KEY } from "./constants";
import transitionEvents from "./transitionEvents";
import ErIcon from "../Icon/Icon.vue";

defineOptions({
name: "ErCollapseItem",
});

const props = defineProps<CollapseItemProps>();

const ctx = inject(COLLAPSE_CTX_KEY);

const isActive = computed(() => ctx?.activeNames.value?.includes(props.name));

function handleClick() {
if (props.disabled) return;
ctx?.handleItemClick(props.name);
}
</script>

<template>
<div
class="er-collapse-item"
:class="{
'is-disabled': disabled,
}"
>
<div
class="er-collapse-item__header"
:id="`item-header-${name}`"
:class="{
'is-disabled': disabled,
'is-active': isActive,
}"
@click="handleClick"
>
<span class="er-collapse-item__title">
<slot name="title">
{{ title }}
</slot>
</span>
<er-icon icon="angle-right" class="header-angle" />
</div>
<transition name="slide" v-on="transitionEvents">
<div class="er-collapse-item__wapper" v-show="isActive">
<div class="er-collapse-item__content" :id="`item-content-${name}`">
<slot></slot>
</div>
</div>
</transition>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ constants.ts

1
2
3
4
5
import type { InjectionKey } from "vue";
import type { CollapseContext } from "./types";

export const COLLAPSE_CTX_KEY: InjectionKey<CollapseContext> =
Symbol("collapseContext");

├─ index.ts

1
2
3
4
5
6
7
8
import Collapse from "./Collapse.vue";
import CollapseItem from "./CollapseItem.vue";
import { withInstall } from "@eric-ui/utils";

export const ErCollapse = withInstall(Collapse);
export const ErCollapseItem = withInstall(CollapseItem);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
.er-collapse {
--er-collapse-border-color: var(--er-border-color-light);
--er-collapse-header-height: 48px;
--er-collapse-header-bg-color: var(--er-fill-color-blank);
--er-collapse-header-text-color: var(--er-text-color-primary);
--er-collapse-header-font-size: 13px;
--er-collapse-content-bg-color: var(--er-fill-color-blank);
--er-collapse-content-font-size: 13px;
--er-collapse-content-text-color: var(--er-text-color-primary);
--er-collapse-disabled-text-color: var(--er-disabled-text-color);
--er-collapse-disabled-border-color: var(--er-border-color-lighter);
border-top: 1px solid var(--er-collapse-border-color);
border-bottom: 1px solid var(--er-collapse-border-color);
}

.er-collapse-item__header {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--er-collapse-header-height);
line-height: var(--er-collapse-header-height);
background-color: var(--er-collapse-header-bg-color);
color: var(--er-collapse-header-text-color);
cursor: pointer;
font-size: var(--er-collapse-header-font-size);
font-weight: 500;
transition: border-bottom-color var(--er-transition-duration);
outline: none;
border-bottom: 1px solid var(--er-collapse-border-color);
&.is-disabled {
color: var(--er-collapse-disabled-text-color);
cursor: not-allowed;
background-image: none;
}
&.is-active {
border-bottom-color: transparent;
.header-angle {
transform: rotate(90deg);
}
}
.header-angle {
transition: transform var(--er-transition-duration);
}
}
.er-collapse-item__content {
will-change: height;
background-color: var(--er-collapse-content-bg-color);
overflow: hidden;
box-sizing: border-box;
font-size: var(--er-collapse-content-font-size);
color: var(--er-collapse-content-text-color);
border-bottom: 1px solid var(--er-collapse-border-color);
padding-bottom: 25px;
}
.slide-enter-active,
.slide-leave-active {
transition: height var(--er-transition-duration) ease-in-out;
}

├─ transitionEvents.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const _setHeightZero = (el: HTMLElement) => (el.style.height = "0px");
const _setHeightScroll = (el: HTMLElement) =>
(el.style.height = `${el.scrollHeight}px`);
const _setHeightEmpty = (el: HTMLElement) => (el.style.height = "");
const _setOverflowHidden = (el: HTMLElement) => (el.style.overflow = "hidden");
const _setOverflowEmpty = (el: HTMLElement) => (el.style.overflow = "");

const transitionEvents: Record<string, (el: HTMLElement) => void> = {
beforeEnter(el) {
_setHeightZero(el);
_setOverflowHidden(el);
},
enter: (el) => _setHeightScroll(el),
afterEnter(el) {
_setHeightEmpty(el);
_setOverflowEmpty(el);
},
beforeLeave(el) {
_setHeightScroll(el);
_setOverflowHidden(el);
},
leave: (el) => _setHeightZero(el),
afterLeave(el) {
_setHeightEmpty(el);
_setOverflowEmpty(el);
},
};

export default transitionEvents;

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { Ref } from "vue";
export type CollapseItemName = string | number;

export interface CollapseProps {
modelValue: CollapseItemName[];
accordion?: boolean;
}

export interface CollapseItemProps {
name: CollapseItemName;
title?: string;
disabled?: boolean;
}

export interface CollapseContext {
activeNames: Ref<CollapseItemName[]>;
handleItemClick(name: CollapseItemName): void;
}

export interface CollapseEmits {
(e: "update:modelValue", value: CollapseItemName[]): void;
(e: "change", value: CollapseItemName[]): void;
}

├─ ConfigProvider

全局配置组件,用于设置组件库的全局属性

–API–

├─ ConfigProvider.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
import type { ConfigProviderProps } from "./types";
import { provideGlobalConfig } from "./hooks";

defineOptions({
name: "ErConfigProvider",
});
const props = defineProps<ConfigProviderProps>();
const config = provideGlobalConfig(props);
</script>

<template>
<slot name="default" :config="config"></slot>
</template>

├─ constants.ts

1
2
3
4
5
6
7
8
9

import type { ConfigProviderProps } from './types'
import type { InjectionKey, Ref } from 'vue'

export type ConfigProviderContext = Partial<ConfigProviderProps>

export const configProviderContextKey: InjectionKey<
Ref<ConfigProviderContext>
> = Symbol()

├─ hooks.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import {
ref,
getCurrentInstance,
inject,
computed,
provide,
unref,
} from "vue";
import type { MaybeRef, Ref, App } from "vue";
import {
configProviderContextKey,
type ConfigProviderContext,
} from "./constants";
import { createI18n, i18nSymbol } from "vue3-i18n";
import type { TranslatePair } from "@eric-ui/locale";
import English from "@eric-ui/locale/lang/en";
import { merge } from "lodash-es";
import { debugWarn } from "@eric-ui/utils";

const globalConfig = ref<ConfigProviderContext>();

export function useGlobalConfig<
K extends keyof ConfigProviderContext,
D extends ConfigProviderContext[K],
>(key: K, defaultVal?: D): Ref<Exclude<ConfigProviderContext[K], void>>;
export function useGlobalConfig(): Ref<ConfigProviderContext>;
export function useGlobalConfig(
key?: keyof ConfigProviderContext,
defaultVal = void 0
) {
const config = getCurrentInstance()
? inject(configProviderContextKey, globalConfig)
: globalConfig;

return key ? computed(() => config.value?.[key] ?? defaultVal) : config;
}

const _createI18n = (opts?: ConfigProviderContext) => {
const mergeMsg = (msg: TranslatePair) =>
merge(msg, opts?.extendsI18nMsg ?? {});
if (!opts?.locale)
return createI18n({
locale: "en",
messages: mergeMsg({
en: English.el,
}),
});

return createI18n({
locale: opts.locale?.name || "en",
messages: mergeMsg({
[opts.locale?.name || "en"]: opts.locale?.el || {},
}),
});
};

export function provideGlobalConfig(
config: MaybeRef<ConfigProviderContext> = { locale: English },
app?: App,
global = false
) {
const instance = getCurrentInstance();
const oldConfig = instance ? useGlobalConfig() : void 0;
const provideFn = app?.provide ?? (instance ? provide : void 0);

if (!provideFn) {
debugWarn(
"provideGlobalConfig",
"provideGlobalConfig() can only be used inside setup()"
);
return;
}
const context = computed(() => {
const cfg = unref(config);
if (!oldConfig?.value) return cfg;
return merge(oldConfig.value, cfg);
});
const i18n = computed(() => {
return _createI18n(context.value);
});

provideFn(configProviderContextKey, context);

provideFn(i18nSymbol, i18n.value);

if (app) app.use(i18n.value);

if (global || !globalConfig.value) globalConfig.value = context.value;

return context;
}

├─ index.ts

1
2
3
4
5
6
7
import ConfigProvider from "./ConfigProvider.vue";
import {withInstall} from '@eric-ui/utils'

export const ErConfigProvider = withInstall(ConfigProvider)

export * from './types'
export * from './hooks'

└─ types.ts

1
2
3
4
5
6
import type { Language, TranslatePair } from "@eric-ui/locale";

export interface ConfigProviderProps {
locale?: Language;
extendsI18nMsg?: TranslatePair;
}

├─ Dropdown

下拉菜单

–API–

├─ constants.ts

1
2
3
4
5
import type { InjectionKey } from "vue";
import type { DropdownContext } from "./types";

export const DROPDOWN_CTX_KEY: InjectionKey<DropdownContext> =
Symbol("dropdownContext");

├─ Dropdown.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { describe, it, test, expect, vi, beforeEach } from "vitest";
import { withInstall } from "@eric-ui/utils";
import { mount } from "@vue/test-utils";
import { ErDropdown, ErDropdownItem } from ".";
import type { DropdownItemProps } from "./types";

import Dropdown from "./Dropdown.vue";
import DropdownItem from "./DropdownItem.vue";

vi.mock("@popperjs/core");

describe("Dropdown/index.ts", () => {
// 测试 withInstall 函数是否被正确应用
it("should be exported with withInstall()", () => {
expect(ErDropdown.install).toBeDefined();
expect(ErDropdownItem.install).toBeDefined();
});

// 测试 Dropdown 组件是否被正确导出
it("should be exported Dropdown component", () => {
expect(ErDropdown).toBe(Dropdown);
expect(ErDropdownItem).toBe(DropdownItem);
});

// 可选:测试 withInstall 是否增强了 Tooltip 组件的功能
test("should enhance Dropdown component", () => {
const enhancedDropdown = withInstall(Dropdown);
expect(enhancedDropdown).toBe(ErDropdown);
// 这里可以添加更多测试,确保 withInstall 增强了组件的特定功能
});

// 可选:如果你的 withInstall 函数有特定的行为或属性,确保它们被正确应用
test("should apply specific enhancements", () => {
const enhancedDropdown = withInstall(Dropdown);
// 例如,如果你的 withInstall 增加了一个特定的方法或属性
expect(enhancedDropdown).toHaveProperty("install");
});
});

describe("Dropdown.vue", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
it("should render slots correctly", () => {
const items: DropdownItemProps[] = [
{ label: "Item 1", command: "item1" },
{ label: "Item 2", command: "item2" },
];

const wrapper = mount(Dropdown, {
props: {
trigger: "click",
},
slots: {
default: () => <button id="trigger">Default slot content</button>,
dropdown: () => items.map((item) => <DropdownItem {...item} />),
},
});

expect(wrapper.text()).toContain("Default slot content");
expect(wrapper.find(".er-dropdown").exists()).toBeTruthy();
});

it("should emit command event when item is clicked", async () => {
const items: DropdownItemProps[] = [
{ label: "Item 1", disabled: true },
{ label: "Item 2", command: "item2", divided: true },
];
const onCommand = vi.fn();
const wrapper = mount(Dropdown, {
props: {
trigger: "click",
onCommand,
},
slots: {
default: () => <button id="trigger">Default slot content</button>,
dropdown: () => items.map((item) => <DropdownItem {...item} />),
},
});

const triggerArea = wrapper.find("#trigger");
expect(triggerArea.exists()).toBeTruthy();

triggerArea.trigger("click");
await vi.runAllTimers();

expect(wrapper.find(".er-dropdown__menu").exists()).toBeTruthy();
await wrapper.findAll("li").at(0)?.trigger("click");
expect(onCommand).toBeCalledTimes(0); // disabled

await wrapper.findAll("li").at(2)?.trigger("click");
expect(onCommand).toBeCalled();
expect(onCommand).toBeCalledWith("item2");
});

it("should toggle visibility when split btn is clicked", async () => {
const items: DropdownItemProps[] = [
{ label: "Item 1" },
{ label: "Item 2", command: "item2" },
];
const onClick = vi.fn();
const wrapper = mount(Dropdown, {
props: {
trigger: "click",
splitButton: true,
items: items,
onClick,
},
slots: {
default: () => <div id="trigger">Default slot content</div>,
},
});

const triggerArea = wrapper.find("#trigger");
expect(triggerArea.exists()).toBeTruthy();
triggerArea.trigger("click");
await vi.runAllTimers();

expect(wrapper.find(".er-dropdown__menu").exists()).toBeFalsy();
expect(onClick).toBeCalled();
});


});

├─ Dropdown.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<script setup lang="ts">
import { ref, computed, provide } from "vue";
import { omit, isNil } from "lodash-es";
import type {
DropdownProps,
DropdownEmits,
DropdownInstance,
DropdownItemProps,
DropdownContext,
} from "./types";
import { useId, useDisabledStyle } from "@eric-ui/hooks";
import { DROPDOWN_CTX_KEY } from "./constants";
import type { TooltipInstance } from "../Tooltip/types";

import { type ButtonInstance, ErButton, ErButtonGroup } from "../Button";
import DropdownItem from "./DropdownItem.vue";
import Tooltip from "../Tooltip/Tooltip.vue";

defineOptions({
name: "ErDropdown",
inheritAttrs: false,
});
const props = withDefaults(defineProps<DropdownProps>(), {
hideOnClick: true,
items: () => [] as DropdownItemProps[],
});
const emits = defineEmits<DropdownEmits>();
const slots = defineSlots();

const tooltipRef = ref<TooltipInstance>();
const triggerRef = ref<ButtonInstance>();

const tooltipProps = computed(() =>
omit(props, ["items", "hideAfterClick", "size", "type", "splitButton"])
);

function handleItemClick(e: DropdownItemProps) {
props.hideOnClick && tooltipRef.value?.hide();
!isNil(e.command) && emits("command", e.command);
}

!TEST && useDisabledStyle();
defineExpose<DropdownInstance>({
open: () => tooltipRef.value?.show(),
close: () => tooltipRef.value?.hide(),
});

provide<DropdownContext>(DROPDOWN_CTX_KEY, {
handleItemClick,
size: computed(() => props.size),
});
</script>

<template>
<div
class="er-dropdown"
:id="`dropdown-${useId().value}`"
:class="{ 'is-disabled': props.disabled }"
>
<tooltip
ref="tooltipRef"
v-bind="tooltipProps"
:virtual-triggering="splitButton"
:virtual-ref="triggerRef"
@visible-change="$emit('visible-change', $event)"
>
<er-button-group
:type="type"
:size="size"
:disabled="disabled"
v-if="splitButton"
>
<er-button @click="$emit('click', $event)">
<slot name="default"></slot>
</er-button>
<er-button ref="triggerRef" icon="angle-down" />
</er-button-group>
<slot v-else name="default"></slot>

<template #content>
<ul class="er-dropdown__menu">
<slot name="dropdown">
<template
v-for="item in items"
:key="item.command ?? useId().value"
>
<dropdown-item v-bind="item" />
</template>
</slot>
</ul>
</template>
</tooltip>
</div>
</template>

<style scoped>
@import "./style.css";

:deep(.er-button-group) {
& > :last-child {
padding: 5px 7px;
}
}
</style>

├─ DropdownItem.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<script setup lang="ts">
import { type DropdownItemProps } from "./types";
import { DROPDOWN_CTX_KEY } from "./constants";
import { useId } from "@eric-ui/hooks";
import { inject, computed } from "vue";

defineOptions({
name: "ErDropdownItem",
});
const props = withDefaults(defineProps<DropdownItemProps>(), {
divided: false,
disabled: false,
command: useId().value,
});

const ctx = inject(DROPDOWN_CTX_KEY);
const size = computed(() => ctx?.size.value);

function handleClick() {
if (props.disabled) return;
ctx?.handleItemClick(props);
}
</script>

<template>
<li v-if="divided" role="separator" class="divided-placeholder"></li>
<li
:id="`dropdown-item-${command ?? useId().value}`"
:class="{
'er-dropdown__item': true,
['er-dropdown__item--' + size]: size,
'is-disabled': disabled,
'is-divided': divided,
}"
@click="handleClick"
>
<slot>
{{ label }}
</slot>
</li>
</template>

<style scoped>
@import "./style.css";
</style>

├─ index.ts

1
2
3
4
5
6
7
8
import Dropdown from "./Dropdown.vue";
import DropdownItem from "./DropdownItem.vue";
import { withInstall } from "@eric-ui/utils";

export const ErDropdown = withInstall(Dropdown);
export const ErDropdownItem = withInstall(DropdownItem);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
.er-dropdown .er-dropdown__menu {
--er-dropdown-menuItem-hover-fill: var(--er-color-primary-light-9);
--er-dropdown-menuItem-hover-color: var(--er-color-primary);
--er-dropdown-menuItem-disabled-color: var(--er-border-color-lighter);
--er-dropdown-menuItem-divided-color: var(--er-border-color-lighter);
}
.er-dropdown {
display: inline-block;
.er-tooltip {
--er-popover-padding: 5px 0;
}
&.is-disabled>*{
color: var(--er-text-color-placeholder) !important;
cursor: not-allowed !important;
}
}
.er-dropdown__menu {
list-style-type: none;
margin: 0;
padding: 0;
.er-dropdown__item {
display: flex;
align-items: center;
white-space: nowrap;
list-style: none;
line-height: 22px;
padding: 5px 16px;
margin: 0;
font-size: var(--er-font-size-base);
color: var(--er-text-color-regular);
cursor: pointer;
outline: none;
&:hover {
background-color: var(--er-dropdown-menuItem-hover-fill);
color: var(--er-dropdown-menuItem-hover-color);
}
&.is-disabled {
color: var(--er-dropdown-menuItem-disabled-color);
cursor: not-allowed;
background-image: none;
}
}

.er-dropdown__item--large {
padding: 7px 20px;
line-height: 22px;
font-size: 14px;
}
.er-dropdown__item--small {
padding: 2px 12px;
line-height: 20px;
font-size: 12px;
}
.divided-placeholder {
margin: 6px 0;
border-top: 1px solid var(--er-dropdown-menuItem-divided-color);
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import type { VNode, ComputedRef } from "vue";
import type { TooltipProps } from "../Tooltip/types";
import type { ButtonType, ButtonSize } from "../Button/types";

export type DropdownCommand = string | number;

export interface DropdownItemProps {
command?: DropdownCommand;
label?: string | VNode;
disabled?: boolean;
divided?: boolean;
}

export interface DropdownProps extends TooltipProps {
type?: ButtonType;
size?: ButtonSize;
items?: DropdownItemProps[];
hideOnClick?: boolean;
splitButton?: boolean;
}

export interface DropdownEmits {
(e: "visible-change", value: boolean): void;
(e: "command", value: DropdownCommand): void;
(e: "click", value: MouseEvent): void;
}

export interface DropdownInstance {
open(): void;
close(): void;
}

export interface DropdownContext {
handleItemClick(item: DropdownItemProps): void;
size: ComputedRef<ButtonSize | void>;
}

├─ Form

表单,用于收集用户输入

–API–

├─ constants.ts

1
2
3
4
5
6
import type { InjectionKey } from "vue";
import type { FormContext, FormItemContext } from "./types";

export const FORM_CTX_KEY: InjectionKey<FormContext> = Symbol("formContext");
export const FORM_ITEM_CTX_KEY: InjectionKey<FormItemContext> =
Symbol("formItemContext");

├─ Form.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<script setup lang="ts">
import { provide, reactive, toRefs } from "vue";
import type {
FormProps,
FormEmits,
FormItemContext,
FormContext,
FormInstance,
FormValidateCallback,
} from "./types";
import type { ValidateFieldsError } from "async-validator";
import { FORM_CTX_KEY } from "./constants";
import { each, filter, includes, size } from "lodash-es";

defineOptions({ name: "ErForm" });
const props = withDefaults(defineProps<FormProps>(), {
showMessage: true,
hideRequiredAsterisk: false,
requiredAsteriskPosition: "left",
labelPosition: "right",
});
const emits = defineEmits<FormEmits>();
const fields: FormItemContext[] = [];

const addField: FormContext["addField"] = function (field) {
if (!field.prop) return;
fields.push(field);
};

const removeField: FormContext["removeField"] = function (field) {
if (!field.prop) return;
fields.splice(fields.indexOf(field), 1);
};

const validate: FormInstance["validate"] = async function (
callback?: FormValidateCallback
) {
return validateField([], callback);
};

const validateField: FormInstance["validateField"] = async function (
keys: string[] = [],
callback?: FormValidateCallback
) {
const filterArr = size(keys)
? filter(fields, (field) => includes(keys, field.prop))
: fields;

try {
const result = await doValidateField(filterArr);
if (result === true) {
callback?.(result);
}
return result;
} catch (e) {
if (e instanceof Error) throw e;
const invalidFields = e as ValidateFieldsError;

callback?.(false, invalidFields);
return Promise.reject(invalidFields);
}
};

const resetFields: FormInstance["resetFields"] = function (
keys: string[] = []
) {
each(filterFields(fields, keys), (field) => field.resetField());
};

const clearValidate: FormInstance["clearValidate"] = function (
keys: string[] = []
) {
each(filterFields(fields, keys), (field) => field.clearValidate());
};

function filterFields(fields: FormItemContext[], props: string[]) {
return size(props)
? filter(fields, (field) => includes(props, field.prop))
: fields;
}

async function doValidateField(fields: FormItemContext[] = []) {
let validationErrors: ValidateFieldsError = {};

for (const field of fields) {
try {
await field.validate("");
} catch (e) {
validationErrors = {
...validationErrors,
...(e as ValidateFieldsError),
};
}
}
if (!size(Object.keys(validationErrors))) return true;
return Promise.reject(validationErrors);
}

const formCtx: FormContext = reactive({
...toRefs(props),
emits,
addField,
removeField,
});

provide<FormContext>(FORM_CTX_KEY, formCtx);

defineExpose<FormInstance>({
validate,
validateField,
resetFields,
clearValidate,
});
</script>

<template>
<form class="er-form">
<slot></slot>
</form>
</template>

├─ FormItem.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
<script setup lang="ts">
import {
type Ref,
computed,
provide,
inject,
onMounted,
onUnmounted,
ref,
nextTick,
watch,
reactive,
watchEffect,
toRefs,
useSlots,
} from "vue";
import Schema, { type RuleItem } from "async-validator";
import type {
FormItemContext,
FormItemProps,
FormValidateFailuer,
FormValidateCallback,
ValidateStatus,
FormItemInstance,
FormItemRule,
} from "./types";
import {
isNil,
get,
size,
filter,
some,
isString,
isNumber,
endsWith,
keys,
includes,
map,
cloneDeep,
isArray,
} from "lodash-es";
import { useId } from "@eric-ui/hooks";
import { FORM_CTX_KEY, FORM_ITEM_CTX_KEY } from "./constants";

defineOptions({ name: "ErFormItem" });
const props = withDefaults(defineProps<FormItemProps>(), {
showMessage: true,
required: void 0,
});
const slots = useSlots();
const ctx = inject(FORM_CTX_KEY);

const labelId = useId().value;

const validateStatus: Ref<ValidateStatus> = ref("init");
const errMsg = ref("");

const inputIds = ref<string[]>([]);

const getValByProp = (target: Record<string, any> | void) => {
if (target && props.prop && !isNil(get(target, props.prop))) {
return get(target, props.prop);
}
return null;
};

const hasLabel = computed(() => !!(props.label || slots.label));
const labelFor = computed(
() => props.for || (inputIds.value.length ? inputIds.value[0] : "")
);

const currentLabel = computed(
() => `${props.label ?? ""}${ctx?.labelSuffix ?? ""}`
);

const innerVal = computed(() => {
const model = ctx?.model;
return getValByProp(model);
});

const itemRules = computed(() => {
const { required } = props;

const rules: FormItemRule[] = [];
if (props.rules) {
rules.push(...props.rules);
}

const formRules = ctx?.rules;
if (formRules && props.prop) {
const _rules = getValByProp(formRules);
if (_rules) {
rules.push(..._rules);
}
}

if (!isNil(required)) {
const requiredRules = filter(
map(rules, (rule, i) => [rule, i]),
(item: [FormItemRule, number]) => includes(keys(item[0]), "required")
);

if (size(requiredRules)) {
for (const item of requiredRules) {
const [rule, i] = item as [FormItemRule, number];
if (rule.required === required) continue;
rules[i] = { ...rule, required };
}
} else {
rules.push({ required });
}
}

return rules;
});

const validateEnabled = computed(() => size(itemRules.value) > 0);
const isRequired = computed(
() =>
!ctx?.hideRequiredAsterisk &&
(some(itemRules.value, "required") || props?.required)
);
const isDisabled = computed(() => ctx?.disabled || props?.disabled);
const propString = computed(() => {
if (!props.prop) return "";
return isString(props.prop) ? props.prop : props.prop.join(".");
});

const normalizeLabelWidth = computed(() => {
const _normalizeStyle = (val: number | string) => {
if (isNumber(val)) return `${val}px`;
return endsWith(val, "px") ? val : `${val}px`;
};

if (props.labelWidth) return _normalizeStyle(props.labelWidth);
if (ctx?.labelWidth) return _normalizeStyle(ctx.labelWidth);
return "150px";
});

let initialVal: any = null;
let isResetting = false;

function getTriggeredRules(trigger?: string) {
const rules = itemRules.value;
if (rules) {
return filter(rules, (r) => {
if (!r.trigger || !trigger) return true;
if (isArray(r.trigger)) {
return r.trigger.includes(trigger);
}
return r.trigger === trigger;
}).map(({ trigger, ...rule }) => rule as RuleItem);
}
return [];
}

async function doValidate(rules: any[]) {
const modelName = propString.value;
const validator = new Schema({ [modelName]: rules });
return validator
.validate({ [modelName]: innerVal.value }, { firstFields: true })
.then(() => {
validateStatus.value = "success";
ctx?.emits("validate", props, true, "");
return true;
})
.catch((err: FormValidateFailuer) => {
const { errors } = err;
validateStatus.value = "error";
errMsg.value = errors && size(errors) > 0 ? errors[0].message ?? "" : "";
ctx?.emits("validate", props, false, errMsg.value);
return Promise.reject(err);
});
}

const validate: FormItemInstance["validate"] = async function (
trigger: string,
callback?: FormValidateCallback
) {
if (isResetting || !props.prop || isDisabled.value) return false;

if (!validateEnabled.value) {
callback?.(false);
return false;
}

const rules = getTriggeredRules(trigger);
if (!size(rules)) {
callback?.(true);
return true;
}

validateStatus.value = "validating";

return doValidate(rules)
.then(() => {
callback?.(true);
return true;
})
.catch((err: FormValidateFailuer) => {
const { fields } = err;
callback?.(false, fields);
return Promise.reject(fields);
});
};
const resetField: FormItemInstance["resetField"] = function () {
const model = ctx?.model;
if (model && propString.value && !isNil(get(model, propString.value))) {
isResetting = true;
model[propString.value] = cloneDeep(initialVal);
}

nextTick(() => clearValidate());
};

const clearValidate: FormItemInstance["clearValidate"] = function () {
validateStatus.value = "init";
errMsg.value = "";
isResetting = false;
};

const addInputId: FormItemContext["addInputId"] = function (id) {
if (!includes(inputIds.value, id)) inputIds.value.push(id);
};

const removeInputId: FormItemContext["removeInputId"] = function (id) {
inputIds.value = filter(inputIds.value, (i) => i != id);
};

const formItemCtx: FormItemContext = reactive({
...toRefs(props),
disabled: isDisabled.value,
validate,
resetField,
clearValidate,
addInputId,
removeInputId,
});

onMounted(() => {
if (props.prop) {
ctx?.addField(formItemCtx);
initialVal = innerVal.value;
}
});

onUnmounted(() => {
if (props.prop) {
ctx?.removeField(formItemCtx);
}
});

watchEffect(() => (formItemCtx.disabled = isDisabled.value));

watch(
() => props.error,
(val) => {
errMsg.value = val || "";
validateStatus.value = val ? "error" : "init";
},
{ immediate: true }
);

provide<FormItemContext>(FORM_ITEM_CTX_KEY, formItemCtx);

defineExpose<FormItemInstance>({
validateMessage: errMsg,
validateStatus,
validate,
resetField,
clearValidate,
});
</script>

<template>
<div
class="er-form-item"
:class="{
'is-error': validateStatus === 'error',
'is-disabled': isDisabled,
'is-required': isRequired,
'asterisk-left': ctx?.requiredAsteriskPosition === 'left',
'asterisk-right': ctx?.requiredAsteriskPosition === 'right',
}"
>
<component
v-if="hasLabel"
class="er-form-item__label"
:class="`position-${ctx?.labelPosition ?? 'right'}`"
:is="labelFor ? 'label' : 'div'"
:id="labelId"
:for="labelFor"
>
<slot name="label" :label="currentLabel">
{{ currentLabel }}
</slot>
</component>
<div class="er-form-item__content">
<slot :validate="validate"></slot>
<div class="er-form-item__error-msg" v-if="validateStatus === 'error'">
<template v-if="ctx?.showMessage && showMessage">
<slot name="error" :error="errMsg">{{ errMsg }}</slot>
</template>
</div>
</div>
</div>
</template>

<style scoped>
@import "./style.css";

.er-form-item {
--er-form-label-width: v-bind(normalizeLabelWidth) !important;
}
</style>

├─ hooks.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import {
computed,
inject,
unref,
ref,
type MaybeRef,
type WatchStopHandle,
onMounted,
watch,
toRef,
onUnmounted,
} from "vue";
import type { FormItemContext } from "./types";
import { FORM_CTX_KEY, FORM_ITEM_CTX_KEY } from "./constants";
import { useProp, useId } from "@eric-ui/hooks";

export function useFormItem() {
const form = inject(FORM_CTX_KEY, void 0);
const formItem = inject(FORM_ITEM_CTX_KEY, void 0);
return { form, formItem };
}

export function useFormDisabled(fallback?: MaybeRef<boolean | void>) {
const disabled = useProp<boolean>("disabled");
const form = inject(FORM_CTX_KEY, void 0);
const formItem = inject(FORM_ITEM_CTX_KEY, void 0);
return computed(
() =>
disabled.value ||
unref(fallback) ||
form?.disabled ||
formItem?.disabled ||
false
);
}

interface UseFormItemInputCommonProps extends Record<string, any> {
id?: string;
}

export function useFormItemInputId(
props: UseFormItemInputCommonProps,
formItemContext?: FormItemContext
) {
const inputId = ref<string>("");
let unwatch: WatchStopHandle | void;

onMounted(() => {
unwatch = watch(
toRef(() => props.id),
(id) => {
const newId = id ?? useId().value;
if (newId !== inputId.value) {
inputId.value && formItemContext?.removeInputId(inputId.value);
formItemContext?.addInputId(newId);
inputId.value = newId;
}
},
{ immediate: true }
);
});

onUnmounted(() => {
unwatch && unwatch();
inputId.value && formItemContext?.removeInputId(inputId.value);
});

return {
inputId,
};
}

├─ index.ts

1
2
3
4
5
6
7
8
9
import Form from "./Form.vue";
import FormItem from "./FormItem.vue";
import { withInstall } from "@eric-ui/utils";

export const ErForm = withInstall(Form);
export const ErFormItem = withInstall(FormItem);

export * from "./types";
export * from "./hooks";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
.er-form-item {
--er-form-label-width: 150px;
--er-form-label-font-size: var(--er-font-size-base);
--er-form-content-font-size: var(--er-font-size-base);
}

.er-form-item {
display: flex;
margin-bottom: 18px;
&:has(> .position-top){
flex-direction: column;
}

.er-form-item__label {
height: 32px;
line-height: 32px;
width: var(--er-form-label-width);
padding: 0;
padding-right: 10px;
box-sizing: border-box;
display: inline-flex;
font-size: var(--er-form-label-font-size);
color: var(--er-text-color-regular);
&.position-right {
padding-left: 12px;
justify-content: flex-end;
}
&.position-left {
padding-right: 12px;
justify-content: flex-start;
}
&.position-top {
padding-bottom: 12px;
justify-content: flex-start;
}
}

.er-form-item__content {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
line-height: 32px;
font-size: var(--er-form-content-font-size);
min-width: 0;
position: relative;
}

.er-form-item__error-msg {
position: absolute;
top: 100%;
left: 0;
padding-top: 2px;
color: var(--er-color-danger);
font-size: 12px;
line-height: 1;
}

&.is-error {
:deep(.er-input__wrapper){
box-shadow: 0 0 0 1px var(--er-color-danger) inset;
}
}

&.is-required.asterisk-right > .er-form-item__label::after{
content: '*';
color: var(--er-color-danger);
margin-left: 4px;
}

&.is-required.asterisk-left > .er-form-item__label::before{
content: '*';
color: var(--er-color-danger);
margin-right: 4px;
}

&.is-disabled > .er-form-item__label{
color: var(--er-disabled-text-color);
cursor: not-allowed;
&::before,&::after{
cursor: not-allowed;
}
}

&.is-disabled > .er-form-item__content{
color: var(--er-disabled-text-color);
cursor: not-allowed;
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import type {
RuleItem,
ValidateError,
ValidateFieldsError,
} from "async-validator";
import type { Ref } from "vue";

export interface FormItemRule extends RuleItem {
trigger?: string | string[];
}
export type FormRules = Record<string, FormItemRule[]>;

export type FormValidateResult = Promise<boolean>;
export type FormValidateCallback = (
isValid: boolean,
invalidFields?: ValidateFieldsError
) => void;

export type ValidateStatus = "success" | "error" | "init" | "validating";

export interface FormValidateFailuer {
errors?: ValidateError[];
fields: ValidateFieldsError;
}

export interface FormProps {
model: Record<string, any>;
rules?: FormRules;
disabled?: boolean;
labelWidth?: number | string;
labelPosition?: "left" | "right" | "top";
labelSuffix?: string;
showMessage?: boolean;
hideRequiredAsterisk?: boolean;
requiredAsteriskPosition?: "left" | "right";
}

export interface FormEmits {
(
event: "validate",
prop: FormItemProps,
isValid: boolean,
message: string
): void;
}

export interface FormItemProps {
prop?: string | string[];
label?: string;
for?: string;
labelWidth?: number | string;
disabled?: boolean;
required?: boolean;
showMessage?: boolean;
error?: string;
rules?: FormItemRule[];
}

export interface FormInstance {
validate(callback?: FormValidateCallback): FormValidateResult;
validateField(
keys?: string[],
callback?: FormValidateCallback
): FormValidateResult;
resetFields(keys?: string[]): void;
clearValidate(keys?: string[]): void;
}

export interface FormItemInstance {
validateStatus: Ref<ValidateStatus>;
validateMessage: Ref<string>;
validate(
trigger: string,
callback?: FormValidateCallback
): FormValidateResult;
resetField(): void;
clearValidate(): void;
}

export interface FormContext extends FormProps {
emits: FormEmits;
addField(field: FormItemContext): void;
removeField(field: FormItemContext): void;
}

export interface FormItemContext extends FormItemProps {
validate(
trigger: string,
callback?: FormValidateCallback
): FormValidateResult;
resetField(): void;
clearValidate(): void;
addInputId(id: string): void;
removeInputId(id: string): void;
}

├─ Icon

图标组件

├─ Icon.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script setup lang="ts">
import { type IconProps } from "./types";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { omit } from "lodash-es";
import { computed } from "vue";

defineOptions({
name: "ErIcon",
inheritAttrs: false,
});

const props = defineProps<IconProps>();

const filterProps = computed(() => omit(props, ["type", "color"]));
const customStyles = computed(() => ({ color: props.color ?? void 0 }));
</script>

<template>
<i
class="er-icon"
:class="{ [`er-icon--${type}`]: type }"
:style="customStyles"
v-bind="$attrs"
>
<font-awesome-icon v-bind="filterProps" />
</i>
</template>

<style scoped>
@import "./style.css";
</style>

├─ index.ts

1
2
3
4
5
6
import Icon from "./Icon.vue";
import { withInstall } from "@eric-ui/utils";

export const ErIcon = withInstall(Icon);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.er-icon {
--er-icon-color: inherit;
display: inline-flex;
justify-content: center;
align-items: center;
position: relative;
fill: currentColor;
color: var(--er-icon-color);
font-size: inherit;
}

@each $val in primary, info, success, warning, danger {
.er-icon--$(val) {
--er-icon-color: var(--er-color-$(val));
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";

export interface IconProps {
border?: boolean;
fixedWidth?: boolean;
flip?: "horizontal" | "vertical" | "both";
icon: object | Array<string> | string | IconDefinition;
mask?: object | Array<string> | string;
listItem?: boolean;
pull?: "right" | "left";
pulse?: boolean;
rotation?: 90 | 180 | 270 | "90" | "180" | "270";
swapOpacity?: boolean;
size?:
| "2xs"
| "xs"
| "sm"
| "lg"
| "xl"
| "2xl"
| "1x"
| "2x"
| "3x"
| "4x"
| "5x"
| "6x"
| "7x"
| "8x"
| "9x"
| "10x";
spin?: boolean;
transform?: object | string;
symbol?: boolean | string;
title?: string;
inverse?: boolean;
bounce?: boolean;
shake?: boolean;
beat?: boolean;
fade?: boolean;
beatFade?: boolean;
spinPulse?: boolean;
spinReverse?: boolean;
type?: "primary" | "success" | "warning" | "danger" | "info";
color?: string;
}

├─ Input

输入框组件, 接收用户输入

–API–

├─ index.ts

1
2
3
4
5
6
import Input from "./Input.vue";
import { withInstall } from "@eric-ui/utils";

export const ErInput = withInstall(Input);

export * from "./types";

├─ Input.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import { describe, expect, test } from "vitest";
import { mount } from "@vue/test-utils";
import Input from "./Input.vue";

describe("Input.vue", () => {
test("render", () => {
const wrapper = mount(Input, {
props: {
type: "text",
size: "small",
modelValue: "test",
},
slots: {
prepend: "prepend",
prefix: "prefix",
},
});
// 针对动态 class 的测试
expect(wrapper.classes()).toContain("er-input");
expect(wrapper.classes()).toContain("er-input--small");
expect(wrapper.classes()).toContain("er-input--text");

expect(wrapper.classes()).toContain("is-prefix");
expect(wrapper.classes()).toContain("is-prepend");

// 正确的标签和节点
expect(wrapper.find("input").exists()).toBeTruthy();
expect(wrapper.get("input").attributes("type")).toBe("text");

// 针对 slots 的测试
expect(wrapper.find(".er-input__prepend").exists()).toBeTruthy();
expect(wrapper.get(".er-input__prepend").text()).toBe("prepend");

expect(wrapper.find(".er-input__prefix").exists()).toBeTruthy();
expect(wrapper.get(".er-input__prefix").text()).toBe("prefix");

// 针对 v-if 的测试
const wrapper2 = mount(Input, {
props: {
type: "textarea",
modelValue: "test",
},
});

expect(wrapper2.find("textarea").exists()).toBeTruthy();
});

test("v-model", async () => {
const wrapper: any = mount(Input, {
props: {
modelValue: "test",
"onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
type: "text",
},
});
const input = wrapper.get("input");

// 初始值
expect(input.element.value).toBe("test");

// 更新值
// setValue 会触发 input 和 change 事件
await input.setValue("test2");
expect(wrapper.props("modelValue")).toBe("test2");
expect(input.element.value).toBe("test2");

expect(wrapper.emitted()).toHaveProperty("input");
expect(wrapper.emitted()).toHaveProperty("change");

const inputEvent = wrapper.emitted("input");
const changeEvent = wrapper.emitted("change");

expect(inputEvent![0]).toEqual(["test2"]);
expect(changeEvent![0]).toEqual(["test2"]);

// v-model 异步更新
await wrapper.setProps({ modelValue: "test3" });
expect(input.element.value).toBe("test3");
});

test("clearable", async () => {
const wrapper = mount(Input, {
props: {
modelValue: "test",
clearable: true,
type: "text",
},
global: {
stubs: ["Icon"],
},
});

// 不应该出现 Icon 区域
expect(wrapper.find(".er-input__clear").exists()).toBeFalsy();

const input = wrapper.get("input");
await input.trigger("focus");
expect(wrapper.emitted()).toHaveProperty("focus");

// 出现 Icon 区域
expect(wrapper.find(".er-input__clear").exists()).toBeTruthy();

// 点击 Icon 区域,触发 clear 事件
await wrapper.get(".er-input__clear").trigger("click");
expect(wrapper.emitted()).toHaveProperty("clear");

expect(wrapper.emitted()).toHaveProperty("input");
expect(wrapper.emitted()).toHaveProperty("change");

const inputEvent = wrapper.emitted("input");
const changeEvent = wrapper.emitted("change");

expect(inputEvent![0]).toEqual([""]);
expect(changeEvent![0]).toEqual([""]);
expect(input.element.value).toBe("");

await input.trigger("blur");
expect(wrapper.emitted()).toHaveProperty("blur");
});

test("toggle password", async () => {
const wrapper = mount(Input, {
props: {
modelValue: "",
type: "password",
showPassword: true,
},
global: {
stubs: ["Icon"],
},
});

// 不应该出现 Icon 区域
expect(wrapper.find(".er-input__password").exists()).toBeFalsy();
const input = wrapper.get("input");

expect(input.element.type).toBe("password");
await input.setValue("123");

const eyeIcon = wrapper.find(".er-input__password");
expect(eyeIcon.exists()).toBeTruthy();
expect(eyeIcon.attributes("icon")).toBe("eye-slash");

// 点击 切换
await eyeIcon.trigger("click");
expect(input.element.type).toBe("text");
// 缓存 Icon
expect(wrapper.find(".er-input__password").attributes("icon")).toBe("eye");
});
});

├─ Input.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
<script setup lang="ts">
import { computed, ref, watch, useAttrs, shallowRef, nextTick } from "vue";
import type { InputProps, InputEmits, InputInstance } from "./types";
import { useFormItem, useFormDisabled, useFormItemInputId } from "../Form";
import { debugWarn } from "@eric-ui/utils";
import { useFocusController } from "@eric-ui/hooks";
import { each, noop } from "lodash-es";
import Icon from "../Icon/Icon.vue";

defineOptions({
name: "ErInput",
inheritAttrs: false,
});
const props = withDefaults(defineProps<InputProps>(), {
type: "text",
autocomplete: "off",
});
const emits = defineEmits<InputEmits>();

const innerValue = ref(props.modelValue);
const passwordVisible = ref(false);

const inputRef = shallowRef<HTMLInputElement>();
const textareaRef = shallowRef<HTMLTextAreaElement>();

const showClear = computed(
() =>
props.clearable &&
!!innerValue.value &&
!isDisabled.value &&
isFocused.value
);

const showPasswordArea = computed(
() =>
props.type === "password" &&
props.showPassword &&
!isDisabled.value &&
!!innerValue.value
);

const _ref = computed(() => inputRef.value || textareaRef.value);

const attrs = useAttrs();
const isDisabled = useFormDisabled();
const { formItem } = useFormItem();
const { inputId } = useFormItemInputId(props, formItem);
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
_ref,
{
afterBlur() {
formItem?.validate("blur").catch((err) => debugWarn(err));
},
}
);

const clear: InputInstance["clear"] = function () {
innerValue.value = "";
each(["update:modelValue", "input", "change"], (e) => emits(e as any, ""));
emits("clear");
formItem?.clearValidate();
};

const focus: InputInstance["focus"] = async function () {
await nextTick();
_ref.value?.focus();
};

const blur: InputInstance["blur"] = function () {
_ref.value?.blur();
};

const select: InputInstance["select"] = function () {
_ref.value?.select();
};

function handleInput() {
emits("update:modelValue", innerValue.value);
emits("input", innerValue.value);
}

function handleChange() {
emits("change", innerValue.value);
}

function togglePasswordVisible() {
passwordVisible.value = !passwordVisible.value;
}

watch(
() => props.modelValue,
(newValue) => {
innerValue.value = newValue;
formItem?.validate("change").catch((err) => debugWarn(err));
}
);

defineExpose<InputInstance>({
ref: _ref,
focus,
blur,
select,
clear,
});
</script>

<template>
<div
class="er-input"
:class="{
[`er-input--${type}`]: type,
[`er-input--${size}`]: size,
'is-disabled': isDisabled,
'is-prepend': $slots.prepend,
'is-append': $slots.append,
'is-prefix': $slots.prefix,
'is-suffix': $slots.suffix,
'is-focus': isFocused,
}"
>
<!-- input -->
<template v-if="type !== 'textarea'">
<!-- prepend slot -->
<div v-if="$slots.prepend" class="er-input__prepend">
<slot name="prepend"></slot>
</div>
<div class="er-input__wrapper" ref="wrapperRef">
<!-- prefix slot -->
<span v-if="$slots.prefix" class="er-input__prefix">
<slot name="prefix"></slot>
</span>
<input
class="er-input__inner"
ref="inputRef"
:id="inputId"
:type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
:disabled="isDisabled"
:readonly="readonly"
:autocomplete="autocomplete"
:placeholder="placeholder"
:autofocus="autofocus"
:form="form"
v-model="innerValue"
v-bind="attrs"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
/>
<!-- suffix slot -->
<span
v-if="$slots.suffix || showClear || showPasswordArea"
class="er-input__suffix"
>
<slot name="suffix"></slot>
<Icon
icon="circle-xmark"
v-if="showClear"
class="er-input__clear"
@click="clear"
@mousedown.prevent="noop"
/>
<Icon
icon="eye"
v-if="showPasswordArea && passwordVisible"
class="er-input__password"
@click="togglePasswordVisible"
/>
<Icon
icon="eye-slash"
v-if="showPasswordArea && !passwordVisible"
class="er-input__password"
@click="togglePasswordVisible"
/>
</span>
</div>
<!-- append slot -->
<div v-if="$slots.append" class="er-input__append">
<slot name="append"></slot>
</div>
</template>

<!-- textarea -->
<template v-else>
<textarea
class="er-textarea__wrapper"
ref="textareaRef"
:id="inputId"
:disabled="isDisabled"
:readonly="readonly"
:autocomplete="autocomplete"
:placeholder="placeholder"
:autofocus="autofocus"
:form="form"
v-model="innerValue"
v-bind="attrs"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
></textarea>
</template>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
.er-input {
--er-input-text-color: var(--er-text-color-regular);
--er-input-border: var(--er-border);
--er-input-hover-border: var(--er-border-color-hover);
--er-input-focus-border: var(--er-color-primary);
--er-input-transparent-border: 0 0 0 1px transparent inset;
--er-input-border-color: var(--er-border-color);
--er-input-border-radius: var(--er-border-radius-base);
--er-input-bg-color: var(--er-fill-color-blank);
--er-input-icon-color: var(--er-text-color-placeholder);
--er-input-placeholder-color: var(--er-text-color-placeholder);
--er-input-hover-border-color: var(--er-border-color-hover);
--er-input-clear-hover-color: var(--er-text-color-secondary);
--er-input-focus-border-color: var(--er-color-primary);
}

.er-input {
--er-input-height: var(--er-component-size);
position: relative;
font-size: var(--er-font-size-base);
display: inline-flex;
width: 100%;
line-height: var(--er-input-height);
box-sizing: border-box;
vertical-align: middle;
&.is-disabled {
cursor: not-allowed;
.er-input__wrapper {
background-color: var(--er-disabled-bg-color);
box-shadow: 0 0 0 1px var(--er-disabled-border-color) inset;
}
.er-input__inner {
color: var(--er-disabled-text-color);
-webkit-text-fill-color: var(--er-disabled-text-color);
cursor: not-allowed;
}
.er-textarea__inner {
background-color: var(--er-disabled-bg-color);
box-shadow: 0 0 0 1px var(--er-disabled-border-color) inset;
color: var(--er-disabled-text-color);
-webkit-text-fill-color: var(--er-disabled-text-color);
cursor: not-allowed;
}
}
&.is-prepend {
>.er-input__wrapper {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.is-append {
>.er-input__wrapper {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&.is-focus .er-input__wrapper {
box-shadow: 0 0 0 1px var(--er-input-focus-border-color) inset!important
}
}

.er-input--large {
--er-input-height: var(--er-component-size-large);
font-size: 14px;
.er-input__wrapper {
padding: 1px 15px;
.er-input__inner {
--er-input-inner-height: calc(var(--er-input-height, 40px) - 2px);
}
}

}
.er-input--small {
--er-input-height: var(--er-component-size-small);
font-size: 12px;
.er-input__wrapper {
padding: 1px 7px;
.er-input__inner {
--er-input-inner-height: calc(var(--er-input-height, 24px) - 2px);
}
}
}
.er-input__prefix, .er-input__suffix {
display: inline-flex;
white-space: nowrap;
flex-shrink: 0;
flex-wrap: nowrap;
height: 100%;
text-align: center;
color: var(--er-input-icon-color, var(--er-text-color-placeholder));
transition: all var(--er-transition-duration);
}
.er-input__prefix {
>:first-child {
margin-left: 0px !important;
}
>:last-child {
margin-right: 8px !important;
}
}
.er-input__suffix {
>:first-child {
margin-left: 8px !important;
}
>:last-child {
margin-right: 0px !important;
}
}
.er-input__prepend, .er-input__append {
background-color: var(--er-fill-color-light);
color: var(--er-color-info);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100%;
border-radius: var(--er-input-border-radius);
padding: 0 20px;
white-space: nowrap;
}
.er-input__prepend {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: 1px 0 0 0 var(--er-input-border-color) inset,0 1px 0 0 var(--er-input-border-color) inset,0 -1px 0 0 var(--er-input-border-color) inset;

}
.er-input__append {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: 0 1px 0 0 var(--er-input-border-color) inset,0 -1px 0 0 var(--er-input-border-color) inset,-1px 0 0 0 var(--er-input-border-color) inset;
& >.er-input__wrapper {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}

.er-input--textarea {
position: relative;
display: inline-block;
width: 100%;
vertical-align: bottom;
font-size: var(--er-font-size-base);
}
.er-textarea__wrapper {
position: relative;
display: block;
resize: vertical;
padding: 5px 11px;
line-height: 1.5;
box-sizing: border-box;
width: 100%;
font-size: inherit;
font-family: inherit;
color: var(--er-input-text-color, var(--er-text-color-regular));
background-color: var(--er-input-bg-color, var(--er-fill-color-blank));
background-image: none;
-webkit-appearance: none;
box-shadow: 0 0 0 1px var(--er-input-border-color, var(--er-border-color)) inset;
border-radius: var(--er-input-border-radius, var(--er-border-radius-base));
transition: var(--er-transition-box-shadow);
border: none;
&:focus {
outline: none;
box-shadow: 0 0 0 1px var(--er-input-focus-border-color) inset;
}
&::placeholder {
color: var(--er-input-placeholder-color);
}
}
.er-input__wrapper {
display: inline-flex;
flex-grow: 1;
align-items: center;
justify-content: center;
padding: 1px 11px;
background-color: var(--er-input-bg-color, var(--er-fill-color-blank));
background-image: none;
border-radius: var(--er-input-border-radius, var(--er-border-radius-base));
transition: var(--er-transition-box-shadow);
box-shadow: 0 0 0 1px var(--er-input-border-color, var(--er-border-color)) inset;
&:hover {
box-shadow: 0 0 0 1px var(--er-input-hover-border-color) inset;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--er-input-focus-border-color) inset;
}
.er-input__inner {
--er-input-inner-height: calc(var(--er-input-height, 32px) - 2px);
width: 100%;
flex-grow: 1;
-webkit-appearance: none;
color: var(--er-input-text-color, var(--er-text-color-regular));
font-size: inherit;
height: var(--er-input-inner-height);
line-height: var(--er-input-inner-height);
padding: 0;
outline: none;
border: none;
background: none;
box-sizing: border-box;
&::placeholder {
color: var(--er-input-placeholder-color);
}
}
.er-icon {
height: inherit;
line-height: inherit;
display: flex;
justify-content: center;
align-items: center;
transition: all var(--er-transition-duration);
margin-left: 8px;
}
.er-input__clear, .er-input__password {
color: var(--er-input-icon-color);
font-size: 14px;
cursor: pointer;
&:hover {
color: var(--er-input-clear-hover-color);
}
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import type { Ref } from "vue";
export interface InputProps {
id?: string;
modelValue: string;
type?: string;
size?: "large" | "small";
disabled?: boolean;
clearable?: boolean;
showPassword?: boolean;

placeholder?: string;
readonly?: boolean;
autocomplete?: string;
autofocus?: boolean;

form?: string;
}

export interface InputEmits {
(e: "update:modelValue", value: string): void;

(e: "input", value: string): void;
// 修改值且 失去焦点 才触发'change'
(e: "change", value: string): void;
(e: "focus", value: FocusEvent): void;
(e: "blur", value: FocusEvent): void;
(e: "clear"): void;
}

export interface InputInstance {
ref: Ref<HTMLInputElement | HTMLTextAreaElement | void>;
focus(): Promise<void>;
blur(): void;
select(): void;
clear(): void;
}

├─ Loading

显示加载状态

–API–

├─ directive.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { type Directive, type DirectiveBinding, type MaybeRef } from "vue";
import type { LoadingOptions } from "./types";
import { type LoadingInstance, Loading } from "./service";

const INSTANCE_KEY = Symbol("loading");

export interface ElementLoading extends HTMLElement {
[INSTANCE_KEY]?: {
instance: LoadingInstance;
options: LoadingOptions;
};
}

function createInstance(
el: ElementLoading,
binding: DirectiveBinding<boolean>
) {
const getProp = <K extends keyof LoadingOptions>(name: K) => {
return el.getAttribute(`er-loading-${name}`) as MaybeRef<string>;
};

const getModifier = <K extends keyof LoadingOptions>(name: K) => {
return binding.modifiers[name];
};

const fullscreen = getModifier("fullscreen");

const options: LoadingOptions = {
text: getProp("text"),
spinner: getProp("spinner"),
background: getProp("background"),
target: fullscreen ? void 0 : el,
body: getModifier("body"),
lock: getModifier("lock"),
fullscreen,
};
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
};
}

export const vLoading: Directive<ElementLoading, boolean> = {
mounted(el, binding) {
if (binding.value) createInstance(el, binding);
},
updated(el, binding) {
if (binding.oldValue === binding.value) return;

if (binding.value && !binding.oldValue) {
createInstance(el, binding);
return;
}

el[INSTANCE_KEY]?.instance?.close();
},

unmounted(el) {
el[INSTANCE_KEY]?.instance.close();
el[INSTANCE_KEY] = void 0;
},
};

├─ index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { vLoading } from "./directive";
import { Loading } from "./service";

import type { App } from "vue";

export const ErLoading = {
name:'ErLoading',
install(app: App) {
app.directive("loading", vLoading);
app.config.globalProperties.$loading = Loading;
},
directive: vLoading,
service: Loading,
};

export default ErLoading;

export {
vLoading,
vLoading as ErLoadingDirective,
Loading as ErLoadingService,
};

export * from "./types";

├─ Loading.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { describe, it, expect } from "vitest";
import { nextTick } from "vue";
import { Loading } from "./service";

export const rAF = async () => {
return new Promise((res) => {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
res(null);
await nextTick();
});
});
});
};

describe("Loading", () => {
it("should creat Loading instance", () => {
const instance = Loading();
expect(instance).toBeTruthy();
});
it('should render mask',async()=>{
Loading();
await rAF();
expect(document.querySelector('.er-loading__mask')).toBeTruthy()
})
it('should close Loading and remove it from DOM',async()=>{
const instance = Loading();

await rAF();
expect(document.querySelector('.er-loading')).toBeTruthy()
instance.close()
await rAF();

expect(document.querySelector('.er-loading')).toBeFalsy()
})
});

├─ Loading.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import type { LoadingOptions } from "./types";
import { computed, type Ref } from "vue";
import { isString } from "lodash-es";
import ErIcon from "../Icon/Icon.vue";

defineOptions({
name: "ErLoading",
inheritAttrs: false,
});
const props = defineProps<LoadingOptions>();

const iconName = computed(() => {
if (isString(props.spinner)) {
return props.spinner;
}
return "spinner"; // 'circle-notch' 也很好看
});
</script>

<template>
<transition name="fade-in-linear" @after-leave="onAfterLeave">
<div
v-show="(props.visible as Ref).value"
class="er-loading er-loading__mask"
:class="{ 'is-fullscreen': fullscreen }"
>
<div class="er-loading__spinner">
<er-icon v-if="props.spinner !== false" :icon="iconName" spin />
<p v-if="text" class="er-loading-text">{{ text }}</p>
</div>
</div>
</transition>
</template>

<style>
@import "./style.css";
.er-loading {
--er-loading-bg-color: v-bind(background) !important;
--er-loading-z-index: v-bind(zIndex) !important;
}
</style>

├─ service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import type { LoadingOptions, LoadingOptionsResolved } from "./types";
import { ref, createApp, reactive, nextTick } from "vue";
import { useZIndex } from "@eric-ui/hooks";
import LoadingComp from "./Loading.vue";
import { delay, isNil, isString } from "lodash-es";

const RELATIVE_CLASS = "er-loading-parent--relative" as const;
const HIDDEN_CLASS = "er-loading-parent--hiden" as const;
const LOADING_NUMB_KEY = "er-loading-numb" as const;

const instanceMap: Map<HTMLElement, LoadingInstance> = new Map();
const { nextZIndex } = useZIndex(30000);

function createLoadingComponent(options: LoadingOptionsResolved) {
const visible = ref(false);
const afterLeaveFlag = ref(false);
const handleAfterLeave = () => {
if (!afterLeaveFlag.value) return;
destroy();
};

const data = reactive({
...options,
onAfterLeave: handleAfterLeave,
});

const setText = (text: string) => (data.text = text);

const destroy = () => {
const target = data.parent;
subtLoadingNumb(target);
if (getLoadingNumb(target)) return;
delay(() => {
removeRelativeClass(target);
removeHiddenClass(target);
}, 1);
instanceMap.delete(target ?? document.body);
vm.$el?.parentNode?.removeChild(vm.$el);
app.unmount();
};

let afterLeaveTimer: number;
const close = () => {
if (options.beforeClose && !options.beforeClose()) return;

afterLeaveFlag.value = true;
clearTimeout(afterLeaveTimer);
afterLeaveTimer = delay(handleAfterLeave, 500);

visible.value = false;
options.closed?.();
};

const app = createApp(LoadingComp, {
...data,
zIndex: data.fullscreen ? nextZIndex() : void 0,
visible,
});
const vm = app.mount(document.createElement("div"));

return {
get $el(): HTMLElement {
return vm.$el;
},
vm,
close,
visible,
setText,
};
}

function resolveOptions(options: LoadingOptions): LoadingOptionsResolved {
let target: HTMLElement;
if (isString(options.target)) {
target = document.querySelector(options.target) ?? document.body;
} else {
target = options.target || document.body;
}
return {
parent: target === document.body || options.body ? document.body : target,
background: options.background ?? "rgba(0, 0, 0, 0.5)",
spinner: options.spinner,
text: options.text,
fullscreen: target === document.body && (options.fullscreen ?? true),
lock: options.lock ?? false,
visible: options.visible ?? true,
target,
};
}

function addRelativeClass(target: HTMLElement = document.body) {
target.classList.add(RELATIVE_CLASS);
}

function removeRelativeClass(target: HTMLElement = document.body) {
target.classList.remove(RELATIVE_CLASS);
}

function addHiddenClass(target: HTMLElement = document.body) {
target.classList.add(HIDDEN_CLASS);
}

function removeHiddenClass(target: HTMLElement = document.body) {
target.classList.remove(HIDDEN_CLASS);
}

function getLoadingNumb(target: HTMLElement = document.body) {
return target.getAttribute(LOADING_NUMB_KEY);
}

function removeLoadingNumb(target: HTMLElement = document.body) {
target.removeAttribute(LOADING_NUMB_KEY);
}

function addLoadingNumb(target: HTMLElement = document.body) {
const numb = getLoadingNumb(target) ?? "0";
target.setAttribute(LOADING_NUMB_KEY, `${Number.parseInt(numb) + 1}`);
}

function subtLoadingNumb(target: HTMLElement = document.body) {
const numb = getLoadingNumb(target);
if (numb) {
const newNumb = Number.parseInt(numb) - 1;
if (newNumb === 0) {
removeLoadingNumb(target);
} else {
target.setAttribute(LOADING_NUMB_KEY, `${newNumb}`);
}
}
}

function addClass(
options: LoadingOptions,
parent: HTMLElement = document.body
) {
if (options.lock) {
addHiddenClass(parent);
} else {
removeHiddenClass(parent);
}

addRelativeClass(parent);
}

let fullscreenInstance: LoadingInstance | null = null;

export type LoadingInstance = ReturnType<typeof createLoadingComponent>;

export function Loading(options: LoadingOptions = {}): LoadingInstance {
const resolved = resolveOptions(options);
const target = resolved.parent ?? document.body;

if (resolved.fullscreen && !isNil(fullscreenInstance)) {
return fullscreenInstance;
}

addLoadingNumb(resolved?.parent);
if (instanceMap.has(target)) {
return instanceMap.get(target)!;
}

const instance = createLoadingComponent({
...resolved,
closed: () => {
resolved.closed?.();

if (resolved.fullscreen) {
fullscreenInstance = null;
}
},
});

addClass(options, resolved?.parent);

resolved.parent?.appendChild(instance.$el);

nextTick(() => (instance.visible.value = !!resolved.visible));

if (resolved.fullscreen) {
fullscreenInstance = instance;
}
instanceMap.set(target, instance);
return instance;
}

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
.er-loading {
--er-loading-icon-color: var(--er-color-primary);
--er-loading-mask-margin: 0;
--er-loading-mask-size: 100%;
--er-loading-icon-size: 42px;
--er-loading-font-size: 14px;
--er-loading-z-index: 20000;
}
.er-loading {
opacity: 1;
transition: opacity var(--er-transition-duration);
&.er-loading__mask {
position: absolute;
margin: var(--er-loading-mask-margin);
top: var(--er-loading-mask-margin);
right: var(--er-loading-mask-margin);
bottom: var(--er-loading-mask-margin);
left: var(--er-loading-mask-margin);
height: var(--er-loading-mask-size);
width: var(--er-loading-mask-size);
z-index: var(--er-loading-z-index);
background: var(--er-loading-bg-color);
display: flex;
justify-content: center;
align-items: center;
&.is-fullscreen {
position: fixed;
}
}
.er-loading__spinner {
color: var(--er-loading-icon-color);
text-align: center;
.er-loading-text {
margin: 3px 0;
font-size: var(--er-loading-font-size);
}
i {
font-size: var(--er-loading-icon-size);
}
}
}
.fade-in-linear-enter-from,
.fade-in-linear-leave-to {
opacity: 0;
}

.er-loading-parent--relative {
position: relative !important;
}
.er-loading-parent--hiden {
overflow: hidden !important;
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { MaybeRef } from "vue";

export interface LoadingOptionsResolved {
parent?: HTMLElement;
target?: HTMLElement;
visible?: MaybeRef<boolean>;
background?: MaybeRef<string>;
spinner?: MaybeRef<boolean | string>;
text?: MaybeRef<string>;
fullscreen?: MaybeRef<boolean>;
lock?: MaybeRef<boolean>;
beforeClose?(): boolean;
closed?(): void;
}

export type LoadingOptions = Partial<
Omit<LoadingOptionsResolved, "parent" | "target"> & {
target: HTMLElement | string;
body: boolean;
zIndex?: number;
onAfterLeave(): void;
}
>;

├─ Message

顶部消息弹出框

–API–

├─ index.ts

1
2
3
4
5
6
import Message from "./methods";
import { withInstallFunction } from "@eric-ui/utils";

export const ErMessage = withInstallFunction(Message, "$message");

export * from "./types";

├─ Message.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { describe, test, expect } from "vitest";
import { nextTick } from "vue";
import { message, closeAll } from "./methods";

export const rAF = async () => {
return new Promise((res) => {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
res(null);
await nextTick();
});
});
});
};

function getTopValue(element: Element) {
const styles = window.getComputedStyle(element);
const topValue = styles.getPropertyValue("top");
return Number.parseFloat(topValue);
}

describe("createMessage", () => {
test("调用方法应该创建对应的 Message 组件", async () => {
const handler = message({ message: "hello msg", duration: 0 });
await rAF();
expect(document.querySelector(".er-message")).toBeTruthy();
handler.close();
await rAF();
expect(document.querySelector(".er-message")).toBeFalsy();
});

test("多次调用应该创建多个实例", async () => {
message({ message: "hello msg", duration: 0 });
message({ message: "hello msg2", duration: 0 });
await rAF();
expect(document.querySelectorAll(".er-message").length).toBe(2);
closeAll();
await rAF();
expect(document.querySelectorAll(".er-message").length).toBe(0);
});

test("创建多个实例应该设置正确的 offset", async () => {
message({ message: "hello msg", duration: 0, offset: 100 });
message({ message: "hello msg2", duration: 0, offset: 50 });
await rAF();
const elements = document.querySelectorAll(".er-message");
expect(elements.length).toBe(2);
// https://github.com/jsdom/jsdom/issues/1590
// jsdom 中获取height的数值都为 0
expect(getTopValue(elements[0])).toBe(100);
expect(getTopValue(elements[1])).toBe(150);
});
});

├─ Message.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<script setup lang="ts">
import type { MessageProps } from "./types";
import { computed, onMounted, ref, watch } from "vue";
import { getLastBottomOffset } from "./methods";
import { delay, bind } from "lodash-es";
import { useEventListener, useOffset } from "@eric-ui/hooks";
import { RenderVnode, typeIconMap } from "@eric-ui/utils";
import ErIcon from "../Icon/Icon.vue";

defineOptions({
name: "ErMessage",
});

const props = withDefaults(defineProps<MessageProps>(), {
type: "info",
duration: 3000,
offset: 10,
transitionName: "fade-up",
});

const visible = ref(false);
const messageRef = ref<HTMLDivElement>();

const iconName = computed(() => typeIconMap.get(props.type) ?? "circle-info");

// div 的高度
const boxHeight = ref(0);

const { topOffset, bottomOffset } = useOffset({
getLastBottomOffset: bind(getLastBottomOffset, props),
offset: props.offset,
boxHeight,
});

const cssStyle = computed(() => ({
top: topOffset.value + "px",
zIndex: props.zIndex,
}));

let timer: number;
function startTimmer() {
if (props.duration === 0) return;
timer = delay(close, props.duration);
}

function clearTimer() {
clearTimeout(timer);
}

function close() {
visible.value = false;
}

onMounted(() => {
visible.value = true;
startTimmer();
});

useEventListener(document, "keydown", (e: Event) => {
const { code } = e as KeyboardEvent;
if (code === "Escape") {
close();
}
});

watch(visible, (val) => {
if (!val) boxHeight.value = -props.offset; // 退出动画更流畅
});

defineExpose({
bottomOffset,
close,
});
</script>

<template>
<Transition
:name="transitionName"
@enter="boxHeight = messageRef!.getBoundingClientRect().height"
@after-leave="!visible && onDestory()"
>
<div
ref="messageRef"
class="er-message"
:class="{
[`er-message--${type}`]: type,
'is-close': showClose,
'text-center': center,
}"
:style="cssStyle"
v-show="visible"
role="alert"
@mouseenter="clearTimer"
@mouseleave="startTimmer"
>
<er-icon class="er-message__icon" :icon="iconName" />
<div class="er-message__content">
<slot>
<render-vnode v-if="message" :vNode="message" />
</slot>
</div>
<div class="er-message__close" v-if="showClose">
<er-icon icon="xmark" @click.stop="close" />
</div>
</div>
</Transition>
</template>

<style scoped>
@import "./style.css";
</style>

├─ methods.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import type {
CreateMessageProps,
MessageInstance,
MessageFn,
Message,
MessageParams,
MessageHandler,
MessageProps,
messageType,
} from "./types";
import { messageTypes } from "./types";
import { render, h, shallowReactive, isVNode } from "vue";
import { findIndex, get, each, set, isString } from "lodash-es";
import { useZIndex, useId } from "@eric-ui/hooks";
import MessageConstructor from "./Message.vue";

const instances: MessageInstance[] = shallowReactive([]);
const { nextZIndex } = useZIndex();

export const messageDefaults = {
type: "info",
duration: 3000,
offset: 10,
transitionName: "fade-up",
} as const;

function normalizeOptions(options: MessageParams): CreateMessageProps {
const result =
!options || isVNode(options) || isString(options)
? {
message: options,
}
: options;

return { ...messageDefaults, ...result } as CreateMessageProps;
}

function createMessage(props: CreateMessageProps): MessageInstance {
const id = useId().value;
const container = document.createElement("div");
const destory = () => {
const idx = findIndex(instances, { id });
if (idx === -1) return;

instances.splice(idx, 1);

render(null, container);
};

const _props = {
...props,
id,
zIndex: nextZIndex(),
onDestory: destory,
};

const vnode = h(MessageConstructor, _props);

render(vnode, container);

document.body.appendChild(container.firstElementChild!);

const vm = vnode.component!;
const handler: MessageHandler = {
close: () => vm.exposed!.close(),
};
const instance: MessageInstance = {
props: _props,
id,
vm,
vnode,
handler,
};
instances.push(instance);

return instance;
}

export const message: MessageFn & Partial<Message> = function (options = {}) {
const normalized = normalizeOptions(options);
const instance = createMessage(normalized);

return instance.handler;
};

export function getLastBottomOffset(this: MessageProps) {
const idx = findIndex(instances, { id: this.id });
if (idx <= 0) return 0;

return get(instances, [idx - 1, "vm", "exposed", "bottomOffset", "value"]);
}

export function closeAll(type?: messageType) {
each(instances, (instance) => {
if (type) {
instance.props.type === type && instance.handler.close();
return;
}
instance.handler.close();
});
}

each(messageTypes, (type) =>
set(message, type, (options: MessageParams) => {
const normalized = normalizeOptions(options);
return message({ ...normalized, type });
})
);

message.closeAll = closeAll;

export default message as Message;

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
.er-message {
--er-message-bg-color: var(--er-color-info-light-9);
--er-message-border-color: var(--er-border-color-lighter);
--er-message-padding: 15px 19px;
--er-message-close-size: 16px;
--er-message-close-icon-color: var(--er-text-color-placeholder);
--er-message-close-hover-color: var(--er-text-color-secondary);
--er-message-icon-size: 16px;
--er-message-icon-margin: 8px;
}

.er-message {
width: fit-content;
max-width: calc(100% - 32px);
box-sizing: border-box;
border-radius: var(--er-border-radius-base);
border-width: var(--er-border-width);
border-style: var(--er-border-style);
border-color: var(--er-message-border-color);
position: fixed;
left: 50%;
top: 20px;
transform: translateX(-50%);
background-color: var(--er-message-bg-color);
padding: var(--er-message-padding);
display: flex;
align-items: center;
transition: top var(--er-transition-duration), opacity var(--er-transition-duration),
transform var(--er-transition-duration);
.er-message__icon {
color: var(--er-message-text-color);
font-size: var(--er-message-icon-size);
width: var(--er-message-icon-size);
margin-right: var(--er-message-icon-margin);
}
.er-message__content {
color: var(--er-message-text-color);
overflow-wrap: break-word;
}
&.is-close .er-message__content {
padding-right: 10px;
}
&.text-center {
justify-content: center;
}
.er-message__close {
display: flex;
align-items: center;
}
.er-message__close .er-icon {
cursor: pointer;
}
}

@each $val in info, success, warning, danger {
.er-message--$(val) {
--er-message-bg-color: var(--er-color-$(val)-light-9);
--er-message-border-color: var(--er-color-$(val)-light-8);
--er-message-text-color: var(--er-color-$(val));
.er-message__close {
--er-icon-color: var(--er-color-$(val));
}
}
}

.er-message--error {
--er-message-bg-color: var(--er-color-danger-light-9);
--er-message-border-color: var(--er-color-danger-light-8);
--er-message-text-color: var(--er-color-danger);
.er-message__close {
--er-icon-color: var(--er-color-danger);
}
}

.er-message.fade-up-enter-from,
.er-message.fade-up-leave-to {
opacity: 0;
transform: translate(-50%, -100%);
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import type { VNode, ComponentInternalInstance } from "vue";

export const messageTypes = [
"info",
"success",
"warning",
"danger",
"error",
] as const;
export type messageType = (typeof messageTypes)[number];

export interface MessageHandler {
close(): void;
}

export type MessageFn = {
(props: MessageParams): MessageHandler;
closeAll(type?: messageType): void;
};

export type MessageTypeFn = (props: MessageParams) => MessageHandler;

export interface Message extends MessageFn {
success: MessageTypeFn;
warning: MessageTypeFn;
info: MessageTypeFn;
danger: MessageTypeFn;
error: MessageTypeFn;
}

export interface MessageProps {
id: string;
message?: string | VNode | (() => VNode);
duration?: number;
showClose?: boolean;
center?: boolean;
type?: messageType;
offset?: number;
zIndex: number;
transitionName?: string;
onDestory(): void;
}

export type MessageOptions = Partial<Omit<MessageProps, "id">>;
export type MessageParams = string | VNode | MessageOptions;

export interface MessageInstance {
id: string;
vnode: VNode;
props: MessageProps;
vm: ComponentInternalInstance;
handler: MessageHandler;
}

export type CreateMessageProps = Omit<
MessageProps,
"onDestory" | "id" | "zIndex"
>;

├─ MessageBox

中心弹出框

–API–

├─ index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import MessageBox from "./methods";
import { set } from "lodash-es";

import type { App } from "vue";

export const ErMessageBox = MessageBox;

set(ErMessageBox, "install", (app: App) => {
app.config.globalProperties.$msgbox = MessageBox;
app.config.globalProperties.$messageBox = MessageBox;
app.config.globalProperties.$alert = MessageBox.alert;
app.config.globalProperties.$confirm = MessageBox.confirm;
app.config.globalProperties.$prompt = MessageBox.prompt;
});

export default ErMessageBox;
export * from "./types";

├─ MessageBox.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import { describe, it, expect, vi } from "vitest";
import { nextTick } from "vue";
import type { MessageBoxType } from "./types";
import MessageBox from "./methods";

export const rAF = async () => {
return new Promise((res) => {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
res(null);
await nextTick();
});
});
});
};

describe("MessageBox Component", () => {
it("renders correctly", async () => {
const props = {
title: "Test Title",
message: "Test Message",
showClose: true,
closeOnClickModal: true,
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
showConfirmButton: true,
};

MessageBox(props);
await rAF();
const header = document.querySelector(".er-message-box__header");
const title = document.querySelector(".er-message-box__title");
const message = document.querySelector(".er-message-box__message");

expect(title).toBeTruthy();
expect(header).toBeTruthy();
expect(message).toBeTruthy();

MessageBox.close();
});

it("closes on close button click", async () => {
const props = {
title: "Test Title",
message: "Test Message",
showClose: true,
};

const doAction = vi.fn();
MessageBox(props).catch((action) => doAction(action));
await rAF();

const closeBtn = document.querySelector(
".er-message-box__header-btn"
) as HTMLButtonElement;
closeBtn.click();

await rAF();

expect(doAction).toHaveBeenCalledWith("close");
});

it("triggers confirm action on confirm button click", async () => {
const props = {
title: "Test Title",
message: "Test Message",
showConfirmButton: true,
showCancelButton: false,
};

const doAction = vi.fn();
MessageBox(props)
.then((action) => doAction(action))
await rAF();

const confirmBtn = document.querySelector(
".er-message-box__footer-btn"
) as HTMLButtonElement;
confirmBtn.click();
await rAF();

expect(doAction).toBeCalledWith('confirm');

});

it("triggers cancel action on cancel button click", async () => {
const props = {
title: "Test Title",
message: "Test Message",
showConfirmButton: true,
showCancelButton: true,
};

const doAction = vi.fn();
MessageBox(props).catch((err) => doAction(err));
await rAF();

const cancelBtn = document.querySelector(
".er-message-box__cancel-btn"
) as HTMLButtonElement;
cancelBtn.click();

await rAF();

expect(doAction).toHaveBeenCalledWith("cancel");
});

it("handles input in prompt mode", async () => {
const props = {
title: "Test Title",
message: "Test Message",
boxType: "prompt" as MessageBoxType,
showInput: true,
};

const doAction = vi.fn();
MessageBox(props).then((res) => doAction(res));
await rAF();

const input = document.querySelector("input") as HTMLInputElement;
input.value = "Test Input";
input.dispatchEvent(new Event("input"));

const confirmBtn = document.querySelector(
".er-message-box__confirm-btn"
) as HTMLButtonElement;
confirmBtn.click();

await rAF();

expect(doAction).toHaveBeenCalledWith({
value: "Test Input",
action: "confirm",
});
});

});

├─ MessageBox.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick, type Ref } from "vue";
import { isFunction, isNil } from "lodash-es";
import type { MessageBoxProps, MessageBoxAction } from "./types";
import type { InputInstance } from "../Input/types";

import ErOverlay from "../Overlay/Overlay.vue";
import ErIcon from "../Icon/Icon.vue";
import ErButton from "../Button/Button.vue";
import ErInput from "../Input/Input.vue";

import { useId, useZIndex } from "@eric-ui/hooks";
import { typeIconMap } from "@eric-ui/utils";

defineOptions({ name: "ErMessageBox", inheritAttrs: false });

const props = withDefaults(defineProps<MessageBoxProps>(), {
lockScroll: true,
showClose: true,
closeOnClickModal: true,
confirmButtonType: "primary",
roundButton: false,
boxType: "",
inputValue: "",
inputPlaceholder:'Please input...',
confirmButtonText: "Ok",
cancelButtonText: "Cancel",
showConfirmButton: true,
});
const { doAction } = props;

const headerRef = ref<HTMLElement>();
const inputRef = ref<InputInstance>();

const inputId = useId();
const { nextZIndex } = useZIndex();

const state = reactive({
...props,
zIndex: nextZIndex(),
});

const hasMessage = computed(() => !!state.message);
const iconComponent = computed(
() => state.icon ?? typeIconMap.get(state.type ?? "")
);

watch(
() => props.visible?.value,
(val) => {
if (val) state.zIndex = nextZIndex();

if (props.boxType !== "prompt") return;

if (!val) return;

nextTick(() => {
inputRef.value && inputRef.value.focus();
});
}
);

function handleWrapperClick() {
props.closeOnClickModal && handleAction("close");
}

function handleInputEnter(e: KeyboardEvent) {
if (state.inputType === "textarea") return;
e.preventDefault();
return handleAction("confirm");
}

function handleAction(action: MessageBoxAction) {
isFunction(props.beforeClose)
? props.beforeClose(action, state, () => doAction(action, state.inputValue))
: doAction(action, state.inputValue);
}

function handleClose() {
handleAction("close");
}
</script>

<template>
<transition name="fade-in-linear" @after-leave="destroy">
<er-overlay v-show="(visible as Ref).value" :z-index="state.zIndex" mask>
<div
role="dialog"
class="er-overlay-message-box"
@click="handleWrapperClick"
>
<div
ref="rootRef"
:class="[
'er-message-box',
{
'is-center': state.center,
},
]"
@click.stop
>
<div
v-if="!isNil(state.title)"
ref="headerRef"
class="er-message-box__header"
:class="{ 'show-close': state.showClose }"
>
<div class="er-message-box__title">
<er-icon
v-if="iconComponent && state.center"
:class="{
[`er-icon-${state.type}`]: state.type,
}"
:icon="iconComponent"
/>
{{ state.title }}
</div>
<button
v-if="showClose"
class="er-message-box__header-btn"
@click.stop="handleClose"
>
<er-icon icon="xmark" />
</button>
</div>
<div class="er-message-box__content">
<er-icon
v-if="iconComponent && !state.center && hasMessage"
:class="{
[`er-icon-${state.type}`]: state.type,
}"
:icon="iconComponent"
/>
<div v-if="hasMessage" class="er-message-box__message">
<slot>
<component
:is="state.showInput ? 'label' : 'p'"
:for="state.showInput ? inputId : void 0"
>
{{ state.message }}
</component>
</slot>
</div>
</div>
<div v-show="state.showInput" class="er-message-box__input">
<er-input
v-model="state.inputValue"
ref="inputRef"
:placeholder="state.inputPlaceholder"
:type="state.inputType"
@keyup.enter="handleInputEnter"
/>
</div>
<div class="er-message-box__footer">
<er-button
v-if="state.showCancelButton"
class="er-message-box__footer-btn er-message-box__cancel-btn"
:type="state.cancelButtonType"
:round="state.roundButton"
:loading="state.cancelButtonLoading"
@click="handleAction('cancel')"
@keydown.prevent.enter="handleAction('cancel')"
>{{ state.cancelButtonText }}</er-button
>
<er-button
v-show="state.showConfirmButton"
class="er-message-box__footer-btn er-message-box__confirm-btn"
:type="state.confirmButtonType ?? 'primary'"
:round="state.roundButton"
:loading="state.confirmButtonLoading"
@click="handleAction('confirm')"
@keydown.prevent.enter="handleAction('confirm')"
>{{ state.confirmButtonText }}</er-button
>
</div>
</div>
</div>
</er-overlay>
</transition>
</template>

<style scoped>
@import "./style.css";
</style>

├─ methods.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import {
createVNode,
isVNode,
ref,
render,
nextTick,
type ComponentPublicInstance,
type VNode,
type VNodeProps,
} from "vue";
import type {
MessageBoxAction,
MessageBoxOptions,
MessageBoxData,
MessageBoxCallback,
MessageBoxProps,
IErMessageBox,
} from "./types";
import MessageBoxConstructor from "./MessageBox.vue";
import {
assign,
each,
isFunction,
isObject,
isString,
isUndefined,
set,
} from "lodash-es";

const messageInstanceMap = new Map<
ComponentPublicInstance<{ doClose: () => void }>,
{
options: MessageBoxOptions;
callback: MessageBoxCallback | void;
resolve: (res: any) => void;
reject: (res: any) => void;
}
>();

function initInstance(props: MessageBoxProps, container: HTMLElement) {
const visible = ref(false);
const isVNodeMsg = isFunction(props?.message) || isVNode(props?.message);
const genDefaultSlot = (message: VNode | (() => VNode)) =>
isFunction(message) ? message : () => message;

const vnode = createVNode(
MessageBoxConstructor,
{
...props,
visible,
} as VNodeProps,
isVNodeMsg ? { default: genDefaultSlot(props.message as VNode) } : void 0
);
render(vnode, container);
document.body.appendChild(container.firstElementChild!);
return vnode.component;
}

function genContainer() {
return document.createElement("div");
}

function showMessage(options: MessageBoxOptions) {
const container = genContainer();
const props: MessageBoxProps = {
...options,
doClose: () => {
vm.visible.value = false;
},
doAction: (action: MessageBoxAction, inputVal: string) => {
const currentMsg = messageInstanceMap.get(vm);
let resolve:
| MessageBoxAction
| { value: string; action: MessageBoxAction };

nextTick(() => vm.doClose());
if (options.showInput) {
resolve = { value: inputVal, action: action };
} else {
resolve = action;
}
if (options.callback) {
options.callback(resolve);
return;
}
if (action === "cancel" || action === "close") {
currentMsg?.reject(action);
return;
}
currentMsg?.resolve(resolve);
},
destroy: () => {
render(null, container);
messageInstanceMap.delete(vm);
},
};

const instance = initInstance(props as MessageBoxProps, container);

const vm = instance?.proxy as ComponentPublicInstance<any>;

vm.visible.value = true;
return vm;
}

async function MessageBox(options: MessageBoxOptions): Promise<MessageBoxData>;

function MessageBox(options: MessageBoxOptions | string | VNode): Promise<any> {
let callback: MessageBoxCallback | void;
if (isString(options) || isVNode(options)) {
options = {
message: options,
};
} else {
callback = options.callback;
}

return new Promise((resolve, reject) => {
const vm = showMessage(options);
messageInstanceMap.set(vm, { options, callback, resolve, reject });
});
}

const MESSAGE_BOX_VARIANTS = ["alert", "confirm", "prompt"] as const;
const MESSAGE_BOX_DEFAULT_OPTS: Record<
(typeof MESSAGE_BOX_VARIANTS)[number],
Partial<MessageBoxOptions>
> = {
alert: { closeOnClickModal: false },
confirm: { showCancelButton: true },
prompt: { showCancelButton: true, showInput: true },
};

each(MESSAGE_BOX_VARIANTS, (type) =>
set(MessageBox, type, messageBoxFactory(type))
);

function messageBoxFactory(boxType: (typeof MESSAGE_BOX_VARIANTS)[number]) {
return (
message: string | VNode,
title: string | MessageBoxOptions,
options: MessageBoxOptions
) => {
let titleOrOpts = "";
if (isObject(title)) {
options = title as MessageBoxOptions;
titleOrOpts = "";
} else if (isUndefined(title)) {
titleOrOpts = "";
} else {
titleOrOpts = title as string;
}

return MessageBox(
assign(
{
title: titleOrOpts,
message,
type: "",
boxType,
...MESSAGE_BOX_DEFAULT_OPTS[boxType],
},
options
)
);
};
}

set(MessageBox, "close", () => {
messageInstanceMap.forEach((_, vm) => {
vm.doClose();
});
messageInstanceMap.clear();
});

export default MessageBox as IErMessageBox;

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
.er-message-box {
--er-message-box-title-color: var(--er-text-color-primary);
--er-message-box-width: 420px;
--er-message-box-border-radius: var(--er-border-radius-base);
--er-message-box-font-size: var(--er-font-size-large);
--er-message-box-content-font-size: var(--er-font-size-base);
--er-message-box-content-color: var(--er-text-color-regular);
--er-message-box-padding-primary: 12px;
--er-message-box-font-line-height: var(--er-font-size-medium);
--er-message-box-close-size: var(--er-message-close-size, 16px);
}

.er-overlay-message-box {
text-align: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 16px;
overflow: auto;

&::after {
content: '';
display: inline-block;
height: 100%;
width: 0;
vertical-align: middle;
}
}

.er-message-box {
display: inline-block;
position: relative;
max-width: var(--er-message-box-width);
width: 100%;
padding: var(--er-message-box-padding-primary);
vertical-align: middle;
background-color: var(--er-bg-color);
border-radius: var(--er-message-box-border-radius);
font-size: var(--er-message-box-font-size);
text-align: left;
overflow: hidden;
backface-visibility: hidden;

box-sizing: border-box;
overflow-wrap: break-word;

&:focus{
outline: none !important;
}

&.is-center {
.er-message-box__title {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}

.er-message-box__footer,.er-message-box__content {
justify-content: center;
}

}

:deep(.er-icon){

@each $val in info, success, warning, danger {
&.er-icon-$(val) {
color: var(--er-color-$(val))!important;
}
}
&.er-icon-error {
color: var(--er-color-danger)!important;
}
}

.er-message-box__header {
padding-bottom: var(--er-message-box-padding-primary);

.er-message-box__title {
color: var(--er-message-box-title-color);
font-size: var(--er-message-box-font-size);
line-height: var(--er-message-box-font-line-height);
}

.er-message-box__header-btn {
position: absolute;
top: 0;
right: 0;
padding: 0;
width: 40px;
height: 40px;
border: none;
outline: none;
background: transparent;
font-size: var(--er-message-box-close-size);
cursor: pointer;

i {
color: var(--er-color-info);
font-size: inherit;
}
&:focus,
&:hover {
i {
color: var(--er-color-danger);
}
}
}

&.show-close {
padding-right: calc(var(--er-message-box-padding-primary) + var(--er-message-box-close-size));
}


}

.er-message-box__content {
display: flex;
align-items: center;
gap: 12px;
font-size: var(--er-message-box-content-font-size);
color: var(--er-message-box-content-color);
.er-message-box__message {
margin: 0;
& p {
margin: 0;
line-height: var(--er-message-box-font-line-height);
}
}
}
.er-message-box__input {
padding-top: 12px;

}

.er-message-box__footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
padding-top: var(--er-message-box-padding-primary);
}

}


.fade-in-linear-enter-active {
.er-overlay-message-box {
animation: msgbox-fade-in var(--er-transition-duration);
}
}

.fade-in-linear-leave-active {
.er-overlay-message-box {
animation: msgbox-fade-in var(--er-transition-duration) reverse;
}
}

@keyframes msgbox-fade-in {
0% {
transform: translate3d(0, -20px, 0);
opacity: 0;
}

100% {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { type Ref, type VNode } from "vue";
import { type messageType } from "../Message/types";
import { type ButtonType } from "../Button/types";

export type MessageBoxAction = "confirm" | "cancel" | "close";
export type MessageBoxType = "" | "prompt" | "alert" | "confirm";
export type MessageBoxCallback = (
action: MessageBoxAction | { value: string; action: MessageBoxAction }
) => void;

export type MessageBoxInputData = {
value: string;
action: MessageBoxAction;
};
export type MessageBoxData = MessageBoxInputData & MessageBoxAction;

export interface MessageBoxOptions {
title?: string;
message?: string | VNode | (() => VNode);
type?: messageType;
boxType?: MessageBoxType;
icon?: string;
callback?: MessageBoxCallback;
showClose?: boolean;
showInput?: boolean;
showCancelButton?: boolean;
showConfirmButton?: boolean;
cancelButtonText?: string;
confirmButtonText?: string;
cancelButtonLoading?: boolean;
confirmButtonLoading?: boolean;
cancelButtonDisabled?: boolean;
confirmButtonDisabled?: boolean;

cancelButtonType?: ButtonType;
confirmButtonType?: ButtonType;
roundButton?: boolean;

center?: boolean;
lockScroll?: boolean;
closeOnClickModal?: boolean;

inputPlaceholder?: string;
inputValue?: string;
inputType?: "text" | "textarea" | "password" | "number";

buttonSize?: "default" | "small" | "large";
beforeClose?: (
action: MessageBoxAction,
instance: MessageBoxOptions,
done: () => void
) => void;
}

export interface MessageBoxProps extends MessageBoxOptions {
visible?: Ref<boolean>;
doClose(): void;
doAction(action: MessageBoxAction, inputVal?: string): void;
destroy(): void;
}

export type MessageBoxShortcutMethod = ((
message: MessageBoxOptions["message"],
title: MessageBoxOptions["title"],
options?: MessageBoxOptions
) => Promise<MessageBoxData>) &
((
message: MessageBoxOptions["message"],
options?: MessageBoxOptions
) => Promise<MessageBoxData>);

export interface IErMessageBox {
(options: MessageBoxOptions | string | VNode): Promise<any>;

alert: MessageBoxShortcutMethod;
confirm: MessageBoxShortcutMethod;
prompt: MessageBoxShortcutMethod;
close(): void;
}

├─ Notification

角落弹出通知框

–API–

├─ index.ts

1
2
3
4
5
6
7
8
9
import Notification from "./methods";
import { withInstallFunction } from "@eric-ui/utils";

export const ErNotification = withInstallFunction(
Notification,
"$notify"
);

export * from "./types";

├─ methods.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import type {
CreateNotificationProps,
NotificationInstance,
NotificationFn,
Notification,
NotificationParams,
NotificationHandler,
NotificationProps,
notificationType,
} from "./types";
import { notificationTypes, notificationPosition } from "./types";
import { shallowReactive, isVNode, render, h } from "vue";
import { each, findIndex, isString, set, get } from "lodash-es";
import { useZIndex, useId } from "@eric-ui/hooks";
import NotificationConstructor from "./Notification.vue";

const { nextZIndex } = useZIndex();

export const notificationDefaults = {
type: "info",
position: "top-right",
duration: 3000,
offset: 20,
transitionName: "fade",
showClose: true,
} as const;

const instancesMap: Map<NotificationProps["position"], NotificationInstance[]> =
new Map();
each(notificationPosition, (key) => instancesMap.set(key, shallowReactive([])));

const getInstancesByPosition = (
position: NotificationProps["position"]
): NotificationInstance[] => instancesMap.get(position)!;

function normalizeOptions(
options: NotificationParams
): CreateNotificationProps {
const result =
!options || isVNode(options) || isString(options)
? { message: options }
: options;

return { ...notificationDefaults, ...result } as CreateNotificationProps;
}

function createNotification(
props: CreateNotificationProps
): NotificationInstance {
const id = useId().value;
const container = document.createElement("div");
const instances = getInstancesByPosition(props.position || "top-right");
const destory = () => {
const idx = findIndex(instances, { id });

if (idx === -1) return;

instances.splice(idx, 1);

render(null, container);
};

const _props = {
...props,
id,
zIndex: nextZIndex(),
onDestory: destory,
};

const vnode = h(NotificationConstructor, _props);
render(vnode, container);

document.body.appendChild(container.firstElementChild!);

const vm = vnode.component!;
const handler: NotificationHandler = {
close: () => vm.exposed?.close(),
};
const instance: NotificationInstance = {
props: _props,
id,
vm,
vnode,
handler,
};
instances.push(instance);
return instance;
}

export const notification: NotificationFn & Partial<Notification> = function (
options = {}
) {
const normalized = normalizeOptions(options);
const instance = createNotification(normalized);
return instance.handler;
};

export function closeAll(type?: notificationType) {
instancesMap.forEach((instances) => {
each(instances, (instance) => {
if (type) {
instance.props.type === type && instance.handler.close();
return;
}
instance.handler.close();
});
});
}

export function getLastBottomOffset(this: NotificationProps) {
const instances = getInstancesByPosition(this.position || "top-right");
const idx = findIndex(instances, { id: this.id });

if (idx <= 0) return 0;

return get(instances, [idx - 1, "vm", "exposed", "bottomOffset", "value"]);
}

each(notificationTypes, (type) => {
set(notification, type, (options: NotificationParams) => {
const normalized = normalizeOptions(options);
return notification({ ...normalized, type });
});
});

notification.closeAll = closeAll;

export default notification as Notification;

├─ Notification.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { describe, test, expect } from "vitest";
import { nextTick } from "vue";
import { notification, closeAll } from "./methods";

export const rAF = async () => {
return new Promise((res) => {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
res(null);
await nextTick();
});
});
});
};

function getTopValue(element: Element) {
const styles = window.getComputedStyle(element);
const topValue = styles.getPropertyValue("top");
return Number.parseFloat(topValue);
}

describe("createMessage", () => {
test("call notification()", async () => {
const handler = notification({ message: "hello msg", duration: 0 });
await rAF();
expect(document.querySelector(".er-notification")).toBeTruthy();
handler.close();
await rAF();
expect(document.querySelector(".er-notification")).toBeFalsy();
});

test('call notification() more times',async()=> {
notification({ message: "hello msg", duration: 0 });
notification({ message: "hello msg", duration: 0 });
await rAF();
expect(document.querySelectorAll(".er-notification").length).toBe(2);
notification.closeAll()
await rAF();
expect(document.querySelectorAll(".er-notification").length).toBe(0);
})

test('offset',async()=>{
notification({ message: "hello msg", duration: 0,offset:100 });
notification({ message: "hello msg", duration: 0,offset:50 });
await rAF()
const elements = document.querySelectorAll(".er-notification")
expect(elements.length).toBe(2)

expect(getTopValue(elements[0])).toBe(100)
expect(getTopValue(elements[1])).toBe(150)
})
});

├─ Notification.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<script setup lang="ts">
import type { NotificationProps } from "./types";
import { ref, computed, onMounted } from "vue";
import { getLastBottomOffset } from "./methods";
import { bind, delay, isString } from "lodash-es";
import { RenderVnode, typeIconMap } from "@eric-ui/utils";
import { useOffset } from "@eric-ui/hooks";

import ErIcon from "../Icon/Icon.vue";

const props = withDefaults(defineProps<NotificationProps>(), {
type: "info",
duration: 3000,
offset: 20,
transitionName: "fade",
showClose: true,
});
const visible = ref(false);
const notifyRef = ref<HTMLDivElement>();

// 这个 div 的高度
const boxHeight = ref(0);

const { topOffset, bottomOffset } = useOffset({
getLastBottomOffset: bind(getLastBottomOffset, props),
offset: props.offset,
boxHeight,
});

const iconName = computed(() => {
if (isString(props.icon)) return props.icon;
return typeIconMap.get(props.type);
});

const horizontalClass = computed(() =>
props.position.endsWith("right") ? "right" : "left"
);

const verticalProperty = computed(() =>
props.position.startsWith("top") ? "top" : "bottom"
);

const cssStyle = computed(() => ({
[verticalProperty.value]: topOffset.value + "px",
zIndex: props.zIndex,
}));

let timer: number;

function startTimer() {
if (props.duration === 0) return;
timer = delay(close, props.duration);
}

function clearTimer() {
clearTimeout(timer);
}

function close() {
visible.value = false;
props?.onClose?.();
}

onMounted(() => {
visible.value = true;
startTimer();
});

defineExpose({
close,
bottomOffset,
});
</script>

<template>
<transition
:name="`er-notification-${transitionName}`"
@after-leave="!visible && onDestory()"
@enter="boxHeight = notifyRef!.getBoundingClientRect().height"
>
<div
ref="notifyRef"
class="er-notification"
:class="{
[`er-notification--${type}`]: type,
'show-close': showClose,
[horizontalClass]: true,
}"
:style="cssStyle"
v-show="visible"
role="alert"
@click="onClick"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<er-icon v-if="iconName" :icon="iconName" class="er-notification__icon" />

<div class="er-notification__text">
<div class="er-notification__title">{{ title }}</div>
<div class="er-notification__content">
<slot>
<render-vnode v-if="message" :vNode="message" />
</slot>
</div>
</div>
<div class="er-notification__close" v-if="showClose">
<er-icon icon="xmark" @click.stop="close" />
</div>
</div>
</transition>
</template>

<style scoped>
@import "./style.css";
</style>

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
.er-notification {
--er-notification-width: 330px;
--er-notification-padding: 14px 26px 14px 13px;
--er-notification-radius: 8px;
--er-notification-shadow: var(--er-box-shadow-light);
--er-notification-border-color: var(--er-border-color-lighter);
--er-notification-icon-size: 24px;
--er-notification-close-font-size: var(--er-message-close-size, 16px);
--er-notification-content-font-size: var(--er-font-size-base);
--er-notification-content-color: var(--er-text-color-regular);
--er-notification-title-font-size: 16px;
--er-notification-title-color: var(--er-text-color-primary);
--er-notification-close-color: var(--er-text-color-secondary);
--er-notification-close-hover-color: var(--er-text-color-regular)
}
.er-notification {
display: flex;
width: var(--er-notification-width);
padding: var(--er-notification-padding);
border-radius: var(--er-notification-radius);
box-sizing: border-box;
border: 1px solid var(--er-notification-border-color);
position: fixed;
background-color: var(--er-bg-color-overlay);
box-shadow: var(--er-notification-shadow);
transition: opacity var(--er-transition-duration),transform var(--er-transition-duration),right var(--er-transition-duration),top .4s,bottom var(--er-transition-duration);
overflow-wrap: anywhere;
overflow: hidden;
z-index: 9999;

&.right {
right: 10px;
}

&.left {
left: 10px;
}

.er-notification__text {
margin: 0 10px;
}
.er-notification__icon {
height: var(--er-notification-icon-size);
width: var(--er-notification-icon-size);
font-size: var(--er-notification-icon-size);
color: var(--er-notification-icon-color);
}

.er-notification__title {
font-weight: 700;
font-size: var(--er-notification-title-font-size);
line-height: var(--er-notification-icon-size);
color: var(--er-notification-title-color);
margin: 0
}
.er-notification__content {
font-size: var(--er-notification-content-font-size);
line-height: 24px;
margin: 6px 0 0;
color: var(--er-notification-content-color);
text-align: justify
}
.er-notification__close {
position: absolute;
top: 18px;
right: 15px;
cursor: pointer;
color: var(--er-notification-close-color);
font-size: var(--er-notification-close-font-size)
}
}
@each $val in info,success,warning,danger {
.er-notification--$(val) {
--er-notification-icon-color: var(--er-color-$(val));
color: var(--el-notification-icon-color);
}
}
.er-notification--error {
--er-notification-icon-color: var(--er-color-danger);
color: var(--el-notification-icon-color);
}

.er-notification-fade-enter-from {
&.right{
right: 0;
transform: translate(100%);
}
&.left{
left:0;
transform: translate(-100%);
}
}
.er-notification-fade-leave-to {
opacity: 0;
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import type { VNode, ComponentInternalInstance } from "vue";

export const notificationTypes = [
"info",
"success",
"warning",
"danger",
] as const;
export type notificationType = (typeof notificationTypes)[number];

export const notificationPosition = [
"top-right",
"top-left",
"bottom-right",
"bottom-left",
] as const;
export type NotificationPosition = (typeof notificationPosition)[number];

export interface NotificationProps {
title: string;
id: string;
zIndex: number;
position: NotificationPosition;
type?: "success" | "info" | "warning" | "danger" | "error";
message?: string | VNode;
duration?: number;
showClose?: boolean;
offset?: number;
transitionName?: string;
icon?: string;
onClick?(): void;
onClose?(): void;
onDestory(): void;
}
export interface NotificationInstance {
id: string;
vnode: VNode;
vm: ComponentInternalInstance;
props: NotificationProps;
handler: NotificationHandler;
}
export type CreateNotificationProps = Omit<
NotificationProps,
"onDestory" | "id" | "zIndex"
>;

export interface NotificationHandler {
close(): void;
}

export type NotificationOptions = Partial<Omit<NotificationProps, "id">>;
export type NotificationParams = string | VNode | NotificationOptions;

export type NotificationFn = {
(props: NotificationParams): NotificationHandler;
closeAll(type?: notificationType): void;
};

export type NotificationTypeFn = (
props: NotificationParams
) => NotificationHandler;

export interface Notification extends NotificationFn {
success: NotificationTypeFn;
warning: NotificationTypeFn;
info: NotificationTypeFn;
danger: NotificationTypeFn;
}

├─ Overlay

遮罩层组件,用于覆盖页面内容

–API–

├─ Overlay.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<script setup lang="ts">
import type { OverlayProps, OverlayEmits } from "./types";

defineOptions({ name: "ErOverlay" });
withDefaults(defineProps<OverlayProps>(), {
mask: true,
});
const emits = defineEmits<OverlayEmits>();

function onMaskClick(e: MouseEvent) {
emits("click", e);
}
</script>

<template>
<div
v-if="mask"
class="er-overlay"
:class="overlayClass"
:style="{ zIndex: zIndex }"
@click="onMaskClick"
>
<slot></slot>
</div>
<div
v-else
:class="overlayClass"
:style="{
zIndex: zIndex,
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
}"
>
<slot></slot>
</div>
</template>

<style scoped>
.er-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
overflow: auto;
z-index: 2000;
}

</style>

└─ types.ts

1
2
3
4
5
6
7
8
9
export interface OverlayProps {
mask?: boolean;
zIndex?: number;
overlayClass?: string | string[] | Record<string, boolean>;
}

export interface OverlayEmits {
(e: "click", value: MouseEvent): void;
}

├─ Popconfirm

气泡确认框

–API–

├─ index.ts

1
2
3
4
5
6
import Popconfirm from "./Popconfirm.vue";
import { withInstall } from "@eric-ui/utils";

export const ErPopconfirm = withInstall(Popconfirm);

export * from "./types";

├─ Popconfirm.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { describe, it, test, expect, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { withInstall } from "@eric-ui/utils";
import { each, get } from "lodash-es";
import type { PopconfirmProps } from "./types";
import { ErPopconfirm } from ".";

import Popconfirm from "./Popconfirm.vue";

const onConfirm = vi.fn();
const onCancel = vi.fn();

describe("Popconfirm/index.ts", () => {
// 测试 withInstall 函数是否被正确应用
it("should be exported with withInstall()", () => {
expect(ErPopconfirm.install).toBeDefined();
});

// 测试 Popconfirm 组件是否被正确导出
it("should be exported Popconfirm component", () => {
expect(ErPopconfirm).toBe(Popconfirm);
});

// 可选:测试 withInstall 是否增强了 Popconfirm 组件的功能
test("should enhance Popconfirm component", () => {
const enhancedPopconfirm = withInstall(Popconfirm);
expect(enhancedPopconfirm).toBe(ErPopconfirm);
// 这里可以添加更多测试,确保 withInstall 增强了组件的特定功能
});

// 可选:如果你的 withInstall 函数有特定的行为或属性,确保它们被正确应用
test("should apply specific enhancements", () => {
const enhancedPopconfirm = withInstall(Popconfirm);
// 例如,如果你的 withInstall 增加了一个特定的方法或属性
expect(enhancedPopconfirm).toHaveProperty("install");
});
});

// 测试组件是否能够接收所有 props
describe("Popconfirm.vue", () => {
const props = {
title: "Test Title",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
confirmButtonType: "primary",
cancelButtonType: "info",
icon: "check-circle",
iconColor: "green",
hideIcon: false,
hideAfter: 500,
width: 200,
} as PopconfirmProps;

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});

it("should accept all props", () => {
const wrapper = mount(Popconfirm, {
props,
});

// 检查 props 是否被正确设置
each(Object.keys(props), (key) => {
expect(get(wrapper.props(), key)).toBe(get(props, key));
});
});

// 测试插槽内容
it("should renders slot content correctly", () => {
const slotContent = "Slot Content";
const wrapper = mount(Popconfirm, {
props,
slots: {
default: slotContent,
},
});

expect(wrapper.text()).toContain(slotContent);
});

test("popconfirm emits", async () => {
const wrapper = mount(() => (
<div>
<div id="outside"></div>
<Popconfirm
title="Test Title"
hideIcon={true}
onConfirm={onConfirm}
onCancel={onCancel}
>
<button id="trigger">trigger</button>
</Popconfirm>
</div>
));
const triggerArea = wrapper.find("#trigger");
expect(triggerArea.exists()).toBeTruthy();

triggerArea.trigger("click");
await vi.runAllTimers();

// 弹出层是否出现
expect(wrapper.find(".er-popconfirm").exists()).toBeTruthy();
const confirmButton = wrapper.find(".er-popconfirm__confirm");
expect(confirmButton.exists()).toBeTruthy();

confirmButton.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-popconfirm").exists()).toBeFalsy();
expect(onConfirm).toBeCalled();

triggerArea.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-popconfirm").exists()).toBeTruthy();
const cancelButton = wrapper.find(".er-popconfirm__cancel");
expect(cancelButton.exists()).toBeTruthy();

await vi.runAllTimers();
cancelButton.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-popconfirm").exists()).toBeFalsy();
expect(onCancel).toBeCalled();
});
});

├─ Popconfirm.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<script lang="ts" setup>
import { ref, computed } from "vue";
import { addUnit } from "@eric-ui/utils";
import type { PopconfirmProps, PopconfirmEmits } from "./types";
import { useLocale } from "@eric-ui/hooks";
import type { TooltipInstance } from "../Tooltip";

import ErButton from "../Button/Button.vue";
import ErIcon from "../Icon/Icon.vue";
import ErTooltip from "../Tooltip/Tooltip.vue";

defineOptions({
name: "ErPopconfirm",
});

const props = withDefaults(defineProps<PopconfirmProps>(), {
title: "",
confirmButtonType: "primary",
icon: "question-circle",
iconColor: "#f90",
hideAfter: 200,
width: 150,
});

const emits = defineEmits<PopconfirmEmits>();
const tooltipRef = ref<TooltipInstance>();
const style = computed(() => ({ width: addUnit(props.width) }));

const { t } = useLocale();

function hidePopper() {
tooltipRef.value?.hide();
}

function confirm(e: MouseEvent) {
emits("confirm", e);
hidePopper();
}

function cancel(e: MouseEvent) {
emits("cancel", e);
hidePopper();
}
</script>

<template>
<er-tooltip ref="tooltipRef" trigger="click" :hide-timeout="hideAfter">
<template #content>
<div class="er-popconfirm" :style="style">
<div class="er-popconfirm__main">
<er-icon v-if="!hideIcon && icon" :icon="icon" :color="iconColor" />
{{ title }}
</div>
<div class="er-popconfirm__action">
<er-button
size="small"
class="er-popconfirm__cancel"
:type="cancelButtonType"
@click="cancel"
>
{{ cancelButtonText || t("popconfirm.cancelButtonText") }}
</er-button>
<er-button
size="small"
class="er-popconfirm__confirm"
:type="confirmButtonType"
@click="confirm"
>
{{ confirmButtonText || t("popconfirm.confirmButtonText") }}
</er-button>
</div>
</div>
</template>

<template v-if="$slots.default" #default>
<slot name="default"></slot>
</template>

<template v-if="$slots.reference" #default>
<slot name="reference"></slot>
</template>
</er-tooltip>
</template>

<style scoped>
@import "./style.css";
</style>

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
.er-popconfirm {
.er-popconfirm__main {
display: flex;
align-items: center;
i {
margin-right: 5px;
}
}
.er-popconfirm__action {
text-align: right;
margin-top: 8px;
}
}

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import type { ButtonType } from "../Button";

export interface PopconfirmProps {
title: string;
confirmButtonText?: string;
cancelButtonText?: string;
confirmButtonType?: ButtonType;
cancelButtonType?: ButtonType;
icon?: string;
iconColor?: string;
hideIcon?: boolean;
hideAfter?: number;
width?: number | string;
}

export interface PopconfirmEmits {
(e: "confirm", value: MouseEvent): void;
(e: "cancel", value: MouseEvent): void;
}

├─ Select

下拉选择框组件,用于从一组选项中选择一个或多个值

–API–

├─ constants.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type { InjectionKey } from "vue";
import type { SelectContext } from "./types";

export const SELECT_CTX_KEY: InjectionKey<SelectContext> =
Symbol("selectContext");

export const POPPER_OPTIONS: any = {
modifiers: [
{
name: "offset",
options: {
offset: [0, 9],
},
},
{
name: "sameWidth",
enabled: true,
fn: ({ state }: { state: any }) => {
state.styles.popper.width = `${state.rects.reference.width}px`;
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
],
} as const;

├─ index.ts

1
2
3
4
5
6
7
8
import Select from "./Select.vue";
import Option from "./Option.vue";
import { withInstall } from "@eric-ui/utils";

export const ErSelect = withInstall(Select);
export const ErOption = withInstall(Option);

export * from "./types";

├─ Option.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<script setup lang="ts">
import type { SelectOptionProps } from "./types";
import { SELECT_CTX_KEY } from "./constants";
import { get, eq, every } from "lodash-es";
import { computed, inject } from "vue";
import { RenderVnode } from "@eric-ui/utils";

defineOptions({
name: "ErOption",
});
const props = withDefaults(defineProps<SelectOptionProps>(), {
disabled: false,
});
const ctx = inject(SELECT_CTX_KEY);
const selected = computed(
() => ctx?.selectStates?.selectedOption?.value === props.value
);
const isHighlighted = computed(() =>
every(["label", "value"], (key) =>
eq(get(ctx, ["highlightedLine", "value", key]), get(props, key))
)
);

function handleClick() {
if (props.disabled) return;
ctx?.handleSelect(props);
}
</script>

<template>
<li
class="er-select__menu-item"
:class="{
'is-disabled': disabled,
'is-selected': selected,
'is-highlighted': isHighlighted,
}"
:id="`select-item-${value}`"
@click.stop="handleClick"
>
<slot>
<render-vnode
:vNode="ctx?.renderLabel ? ctx?.renderLabel(props) : label"
/>
</slot>
</li>
</template>

<style scoped>
@import "./style.css";
</style>

├─ Select.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
<script setup lang="ts">
import {
assign,
find,
get,
each,
noop,
isFunction,
filter,
isNil,
isBoolean,
includes,
map,
size,
eq,
debounce,
} from "lodash-es";
import {
ref,
reactive,
computed,
onMounted,
provide,
useSlots,
watch,
h,
nextTick,
type Ref,
type VNode,
} from "vue";
import type {
SelectProps,
SelectEmits,
SelectOptionProps,
SelectContext,
SelectInstance,
SelectStates,
} from "./types";
import type { TooltipInstance } from "../Tooltip/types";
import type { InputInstance } from "../Input/types";
import { RenderVnode, debugWarn } from "@eric-ui/utils";
import { useFocusController, useClickOutside } from "@eric-ui/hooks";
import { SELECT_CTX_KEY, POPPER_OPTIONS } from "./constants";
import { useFormItem, useFormDisabled, useFormItemInputId } from "../Form";

import useKeyMap from "./useKeyMap";
import ErTooltip from "../Tooltip/Tooltip.vue";
import ErIcon from "../Icon/Icon.vue";
import ErInput from "../Input/Input.vue";
import ErOption from "./Option.vue";

const COMPONENT_NAME = "ErSelect" as const;
defineOptions({
name: COMPONENT_NAME,
});
const props = withDefaults(defineProps<SelectProps>(), {
options: () => [],
});
const emits = defineEmits<SelectEmits>();

const initialOption = findOption(props.modelValue);

const selectRef = ref<HTMLElement>();
const tooltipRef = ref<TooltipInstance>();
const inputRef = ref<InputInstance>();

const isDropdownVisible = ref(false);
const filteredOptions = ref(props.options ?? []);
const filteredChilds: Ref<Map<VNode, SelectOptionProps>> = ref(new Map());

const selectStates = reactive<SelectStates>({
inputValue: initialOption?.label ?? "",
selectedOption: initialOption,
mouseHover: false,
loading: false,
highlightedIndex: -1,
});

const slots = useSlots();
const isDisabled = useFormDisabled();
const { formItem } = useFormItem();
const { inputId } = useFormItemInputId(props, formItem);
const {
wrapperRef: inputWrapperRef,
isFocused,
handleBlur,
handleFocus,
} = useFocusController(inputRef);

useClickOutside(selectRef, (e) => handleClickOutsie(e));

const highlightedLine = computed(() => {
let result: SelectOptionProps | void;
if (hasChildren.value) {
const node = [...filteredChilds.value][selectStates.highlightedIndex]?.[0];
result = filteredChilds.value.get(node);
} else {
result = filteredOptions.value[selectStates.highlightedIndex];
}
return result;
});

const children = computed(() =>
filter(slots?.default?.(), (child) => eq(child.type, ErOption))
);

const hasChildren = computed(() => size(children.value) > 0);
const childrenOptsions = computed(() => {
if (!hasChildren.value) return [];
return map(children.value, (item) => ({
vNode: h(item),
props: assign(item.props, {
disabled:
item.props?.disabled === true ||
(!isNil(item.props?.disabled) && !isBoolean(item.props?.disabled)),
}),
}));
});

const filterPlaceholder = computed(() => {
return props.filterable &&
selectStates.selectedOption &&
isDropdownVisible.value
? selectStates.selectedOption.label
: props.placeholder;
});

const timeout = computed(() => (props.remote ? 300 : 0));

const hasData = computed(
() =>
(hasChildren.value && filteredChilds.value.size > 0) ||
(!hasChildren.value && size(filteredOptions.value) > 0)
);

const isNoData = computed(() => {
if (!props.filterable) return false;
if (!hasData.value) return true;
return false;
});

const lastIndex = computed(() =>
hasChildren.value
? filteredChilds.value.size - 1
: size(filteredOptions.value) - 1
);

const showClear = computed(
() =>
props.clearable && selectStates.mouseHover && selectStates.inputValue !== ""
);

const handleFilterDebounce = debounce(handleFilter, timeout.value);

const keyMap = useKeyMap({
isDropdownVisible,
controlVisible,
selectStates,
highlightedLine,
handleSelect,
hasData,
lastIndex,
});

const focus: SelectInstance["focus"] = function () {
inputRef.value?.focus();
};

const blur: SelectInstance["blur"] = function () {
handleClickOutsie();
};

function setFilteredChilds(opts: typeof childrenOptsions.value) {
filteredChilds.value.clear();
each(opts, (item) => {
filteredChilds.value.set(item.vNode, item.props as SelectOptionProps);
});
}

function renderLabel(opt: SelectOptionProps): VNode | string {
if (isFunction(props.renderLabel)) {
return props.renderLabel(opt);
}
return opt.label;
}

function controlVisible(visible: boolean) {
if (!tooltipRef.value) return;
get(tooltipRef, ["value", visible ? "show" : "hide"])?.();
props.filterable && controlInputVal(visible);
isDropdownVisible.value = visible;
emits("visible-change", visible);

selectStates.highlightedIndex = -1;
}
function controlInputVal(visible: boolean) {
if (!props.filterable) return;
if (visible) {
if (selectStates.selectedOption) selectStates.inputValue = "";
handleFilterDebounce();
} else {
selectStates.inputValue = selectStates.selectedOption?.label || "";
}
}
function toggleVisible() {
if (isDisabled.value) return;
controlVisible(!isDropdownVisible.value);
}

function findOption(value: string) {
return find(props.options, (option) => option.value === value);
}

function handleClickOutsie(e?: Event) {
if (isFocused.value) {
nextTick(() => handleBlur(new FocusEvent("focus", e)));
}
}

function handleSelect(o: SelectOptionProps) {
if (o.disabled) return;

selectStates.inputValue = o.label;
selectStates.selectedOption = o;
each(["change", "update:modelValue"], (k) => emits(k as any, o.value));
controlVisible(false);
inputRef.value?.focus();
}

function setSelected() {
const option = findOption(props.modelValue);
if (!option) return;
selectStates.inputValue = option.label;
selectStates.selectedOption = option;
}

function handleClear() {
inputRef.value?.clear();
selectStates.inputValue = "";
selectStates.selectedOption = null;

emits("clear");
each(["change", "update:modelValue"], (k) => emits(k as any, ""));
formItem?.clearValidate();
}

async function callRemoteMethod(method: Function, search: string) {
if (!method || !isFunction(method)) return;

selectStates.loading = true;
let result;
try {
result = await method(search);
} catch (error) {
debugWarn(error as Error);
debugWarn(COMPONENT_NAME, "callRemoteMethod error");
result = [];
return Promise.reject(error);
} finally {
selectStates.loading = false;
}
return result;
}

async function genFilterOptions(search: string) {
if (!props.filterable) return;

if (props.remote && props.remoteMethod && isFunction(props.remoteMethod)) {
filteredOptions.value = await callRemoteMethod(props.remoteMethod, search);
return;
}

if (props.filterMethod && isFunction(props.filterMethod)) {
filteredOptions.value = props.filterMethod(search);
return;
}

filteredOptions.value = filter(props.options, (opt) =>
includes(opt.label, search)
);
}

async function genFilterChilds(search: string) {
if (!props.filterable) return;

if (props.remote && props.remoteMethod && isFunction(props.remoteMethod)) {
await callRemoteMethod(props.remoteMethod, search);
setFilteredChilds(childrenOptsions.value);
return;
}
if (props.filterMethod && isFunction(props.filterMethod)) {
const options = map(props.filterMethod(search), "value");
setFilteredChilds(
filter(childrenOptsions.value, (item) =>
includes(options, get(item, ["props", "value"]))
)
);
return;
}
setFilteredChilds(
filter(childrenOptsions.value, (item) =>
includes(get(item, ["props", "label"]), search)
)
);
}

function handleFilter() {
const searchKey = selectStates.inputValue;
selectStates.highlightedIndex = -1;

if (hasChildren.value) {
genFilterChilds(searchKey);
return;
}
genFilterOptions(searchKey);
}

function handleKeyDown(e: KeyboardEvent) {
keyMap.has(e.key) && keyMap.get(e.key)?.(e);
}

watch(
() => props.options,
(newOpts) => {
filteredOptions.value = newOpts ?? [];
}
);

watch(
() => childrenOptsions.value,
(newOpts) => setFilteredChilds(newOpts),
{ immediate: true }
);

watch(
() => props.modelValue,
(newVal, oldVal) => {
if (newVal !== oldVal) {
formItem?.validate("change").catch((err) => debugWarn(err));
}
setSelected();
}
);

onMounted(() => {
setSelected();
});

provide<SelectContext>(SELECT_CTX_KEY, {
handleSelect,
selectStates,
renderLabel,
highlightedLine,
});

defineExpose<SelectInstance>({
focus,
blur,
});
</script>

<template>
<div
ref="selectRef"
class="er-select"
:class="{
'is-disabled': isDisabled,
}"
@click.stop="toggleVisible"
@mouseenter="selectStates.mouseHover = true"
@mouseleave="selectStates.mouseHover = false"
>
<er-tooltip
ref="tooltipRef"
placement="bottom-start"
:popper-options="POPPER_OPTIONS"
@click-outside="controlVisible(false)"
manual
>
<template #default>
<div ref="inputWrapperRef">
<er-input
ref="inputRef"
v-model="selectStates.inputValue"
:id="inputId"
:disabled="isDisabled"
:placeholder="filterable ? filterPlaceholder : placeholder"
:readonly="!filterable || !isDropdownVisible"
@focus="handleFocus"
@blur="handleBlur"
@input="handleFilterDebounce"
@keydown="handleKeyDown"
>
<template #suffix>
<er-icon
v-if="showClear"
icon="circle-xmark"
class="er-input__clear"
@click.stop="handleClear"
@mousedown.prevent="noop"
/>
<er-icon
v-else
class="header-angle"
icon="angle-down"
:class="{ 'is-active': isDropdownVisible }"
/>
</template>
</er-input>
</div>
</template>
<template #content>
<div class="er-select__loading" v-if="selectStates.loading">
<er-icon icon="spinner" spin />
</div>
<div class="er-select__nodata" v-else-if="filterable && isNoData">
No data
</div>
<ul class="er-select__menu" v-else>
<template v-if="!hasChildren">
<er-option
v-for="item in filteredOptions"
:key="item.value"
v-bind="item"
/>
</template>
<template v-else>
<template
v-for="[vNode, _props] in filteredChilds"
:key="_props.value"
>
<render-vnode :vNode="vNode" />
</template>
</template>
</ul>
</template>
</er-tooltip>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
.er-select {
--er-select-item-hover-bg-color: var(--er-fill-color-light);
--er-select-item-font-size: var(--er-font-size-base);
--er-select-item-font-color: var(--er-text-color-regular);
--er-select-item-selected-font-color: var(--er-color-primary);
--er-select-item-disabled-font-color: var(--er-text-color-placeholder);
--er-select-input-focus-border-color: var(--er-color-primary);
}

.er-select{
display: inline-block;
vertical-align: middle;
line-height: 32px;
:deep(.er-tooltip__popper) {
padding: 0;
}

:deep(.er-input){
.header-angle {
transition: transform var(--er-transition-duration);
&.is-active {
transform: rotate(180deg);
}
}
}

.er-select__nodata, .er-select__loading {
padding: 10px 0;
margin: 0;
text-align: center;
color: var(--er-text-color-secondary);
font-size: var(--er-select-font-size);
}
.er-select__menu {
list-style: none;
margin: 6px 0;
padding: 0;
box-sizing: border-box;
}
.er-select__menu-item {
margin: 0;
font-size: var(--er-select-item-font-size);
padding: 0 32px 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--er-select-item-font-color);
height: 34px;
line-height: 34px;
box-sizing: border-box;
cursor: pointer;
&:hover {
background-color: var(--er-select-item-hover-bg-color);
}
&.is-selected {
color: var(--er-select-item-selected-font-color);
font-weight: 700;
}
&.is-highlighted {
background-color: var(--er-select-item-hover-bg-color);
}
&.is-disabled {
color: var(--er-select-item-disabled-font-color);
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}

}

:deep(.er-input__inner) {
cursor: pointer;
}
}

├─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import type { VNode, ComputedRef } from "vue";

export type RenderLabelFunc = (option: SelectOptionProps) => VNode | string;
export type CustomFilterFunc = (value: string) => SelectOptionProps[];
export type CustomFilterRemoteFunc = (
value: string
) => Promise<SelectOptionProps[] | void>;

export interface SelectOptionProps {
value: string;
label: string;
disabled?: boolean;
}

export interface SelectProps {
modelValue: string;
id?: string;
options?: SelectOptionProps[];
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
renderLabel?: RenderLabelFunc;
filterable?: boolean;
filterMethod?: CustomFilterFunc;
remote?: boolean;
remoteMethod?: CustomFilterRemoteFunc;
}

export interface SelectStates {
inputValue: string;
selectedOption: SelectOptionProps | void | null;
mouseHover: boolean;
loading: boolean;
highlightedIndex: number;
}

export interface SelectEmits {
(e: "update:modelValue", value: string): void;
(e: "change", value: string): void;
(e: "visible-change", vlaue: boolean): void;

(e: "clear"): void;
(e: "focus"): void;
(e: "blur"): void;
}

export interface SelectContext {
selectStates: SelectStates;
renderLabel?: RenderLabelFunc;
highlightedLine?: ComputedRef<SelectOptionProps | void>;
handleSelect(item: SelectOptionProps): void;
}

export interface SelectInstance {
focus(): void;
blur(): void;
}

└─ useKeyMap.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import type { Ref, ComputedRef } from "vue";
import type { SelectOptionProps, SelectStates } from "./types";

interface KeyMapParams {
isDropdownVisible: Ref<boolean>;
highlightedLine: ComputedRef<SelectOptionProps | void>;
hasData: ComputedRef<boolean>;
lastIndex: ComputedRef<number>;
selectStates: SelectStates;
controlVisible(visible: boolean): void;
handleSelect(option: SelectOptionProps): void;
}

export default function useKeyMap({
isDropdownVisible,
controlVisible,
selectStates,
highlightedLine,
handleSelect,
hasData,
lastIndex,
}: KeyMapParams) {
const keyMap: Map<string, Function> = new Map();

keyMap.set("Enter", () => {
if (!isDropdownVisible.value) {
controlVisible(true);
} else {
if (selectStates.highlightedIndex >= 0 && highlightedLine.value) {
handleSelect(highlightedLine.value);
} else {
controlVisible(false);
}
}
});
keyMap.set(
"Escape",
() => isDropdownVisible.value && controlVisible(!isDropdownVisible.value)
);
keyMap.set("ArrowUp", (e: KeyboardEvent) => {
e.preventDefault();
if (!hasData.value) return;
if (
selectStates.highlightedIndex === -1 ||
selectStates.highlightedIndex === 0
) {
selectStates.highlightedIndex = lastIndex.value;
return;
}
selectStates.highlightedIndex--;
});

keyMap.set("ArrowDown", (e: KeyboardEvent) => {
e.preventDefault();
if (!hasData.value) return;
if (
selectStates.highlightedIndex === -1 ||
selectStates.highlightedIndex === lastIndex.value
) {
selectStates.highlightedIndex = 0;
return;
}
selectStates.highlightedIndex++;
});

return keyMap;
}

├─ Switch

开关组件,用于在两种状态之间切换

–API–

├─ index.ts

1
2
3
4
5
6
import Switch from "./Switch.vue";
import { withInstall } from "@eric-ui/utils";

export const ErSwitch = withInstall(Switch);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
.er-switch {
--er-switch-on-color: var(--er-color-primary);
--er-switch-off-color: var(--er-border-color);
--er-switch-on-border-color: var(--er-color-primary);
--er-switch-off-border-color: var(--er-border-color);
}

.er-switch {
display: inline-flex;
align-items: center;
font-size: 14px;
line-height: 20px;
height: 32px;
.er-switch__input{
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
&:focus-visible{
& ~ .er-switch__core{
outline: 2px solid var(--er-switch-on-color);
outline-offset: 1px;
}
}
}
&.is-disabled {
opacity: .6;
.er-switch__core {
cursor: not-allowed;
}
}
&.is-checked{
.er-switch__core{
border-color: var(--er-switch-on-border-color);
background-color: var(--er-switch-on-color);
.er-switch__core-action{
left: calc(100% - 17px);
}
.er-switch__core-inner {
padding: 0 18px 0 4px;
}
}
}
}

.er-switch--large {
font-size: 14px;
line-height: 24px;
height: 40px;
.er-switch__core {
min-width: 50px;
height: 24px;
border-radius: 12px;
.er-switch__core-action {
width: 20px;
height: 20px;
}
}
&.is-checked {
.er-switch__core .er-switch__core-action {
left: calc(100% - 21px);
color: var(--er-switch-on-color);
}
}
}
.er-switch--small {
font-size: 12px;
line-height: 16px;
height: 24px;
.er-switch__core {
min-width: 30px;
height: 16px;
border-radius: 8px;
.er-switch__core-action {
width: 12px;
height: 12px;
}
}
&.is-checked {
.er-switch__core .er-switch-core-action {
left: calc(100% - 13px);
color: var(--er-switch-on-color);
}
}
}

.er-switch__core{
cursor: pointer;
display: inline-flex;
align-items: center;
position: relative;
height: 20px;
min-width: 40px;
border:1px solid var(--er-switch-off-border-color);
outline: none;
border-radius: 10px;
box-sizing: border-box;
background: var(--er-switch-off-color);
transition: border-color var(--er-transition-duration),background-color var(--er-transition-duration);
.er-switch__core-action{
position: absolute;
border-radius: var(--er-border-radius-circle);
left: 1px;
width: 16px;
height: 16px;
background-color: var(--er-color-white);
transition: all var(--er-transition-duration);
}
.er-switch__core-inner{
width: 100%;
height: 16px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
padding: 0 4px 0 18px;
.er-switch__core-inner-text{
font-size: 12px;
color: var(--er-color-white);
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

}
}
}

├─ Switch.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Switch from './Switch.vue'; // 确保这是你的 Switch 组件的路径

describe('Switch.vue', () => {
it('should render correctly with default props', () => {
const wrapper = mount(Switch);
expect(wrapper.find('.er-switch')).toBeTruthy();
});

it('should handle click event and toggle the checked state', async () => {
const wrapper = mount(Switch, {
props: {
modelValue: false,
},
});

await wrapper.trigger('click');
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([true]);
expect(wrapper.emitted()['change'][0]).toEqual([true]);

await wrapper.trigger('click');
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([false]);
expect(wrapper.emitted()['change'][1]).toEqual([false]);
});

it('should not toggle when disabled', async () => {
const wrapper = mount(Switch, {
props: {
modelValue: false,
disabled: true,
},
});

await wrapper.trigger('click');
expect(wrapper.emitted()).not.toHaveProperty('update:modelValue');
expect(wrapper.emitted()).not.toHaveProperty('change');
});

});

├─ Switch.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import type { SwitchEmits, SwitchProps, SwitchInstance } from "./types";
import { debugWarn } from "@eric-ui/utils";
import { useFormItem, useFormDisabled, useFormItemInputId } from "../Form";

defineOptions({ name: "ErSwitch", inheritAttrs: false });
const props = withDefaults(defineProps<SwitchProps>(), {
activeValue: true,
inactiveValue: false,
});
const emits = defineEmits<SwitchEmits>();
const isDisabled = useFormDisabled();
const { formItem } = useFormItem();
const { inputId } = useFormItemInputId(props, formItem);

const innerValue = ref(props.modelValue);
const inputRef = ref<HTMLInputElement | null>(null);
const checked = computed(() => innerValue.value === props.activeValue);

const focus: SwitchInstance["focus"] = function () {
inputRef.value?.focus();
};

function handleChange() {
if (isDisabled.value) return;

const newVal = checked.value ? props.inactiveValue : props.activeValue;

innerValue.value = newVal;
emits("update:modelValue", newVal);
emits("change", newVal);
}

onMounted(() => {
inputRef.value!.checked = checked.value;
});
watch(checked, (val) => {
inputRef.value!.checked = val;
formItem?.validate("change").catch((err) => debugWarn(err));
});
watch(
() => props.modelValue,
(val) => (innerValue.value = val)
);

defineExpose<SwitchInstance>({
focus,
checked,
});
</script>

<template>
<div
class="er-switch"
:class="{
[`er-switch--${size}`]: size,
'is-disabled': isDisabled,
'is-checked': checked,
}"
@click="handleChange"
>
<input
class="er-switch__input"
type="checkbox"
role="switch"
ref="inputRef"
:id="inputId"
:name="name"
:disabled="isDisabled"
:checked="checked"
@keydown.enter="handleChange"
@blur="formItem?.validate('blur').catch((err) => debugWarn(err))"
/>
<div class="er-switch__core">
<div class="er-switch__core-inner">
<span
v-if="activeText || inactiveText"
class="er-switch__core-inner-text"
>
{{ checked ? activeText : inactiveText }}
</span>
</div>
<div class="er-switch__core-action"></div>
</div>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

└─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type { ComputedRef } from "vue";

export type SwitchValueType = boolean | string | number;

export interface SwitchProps {
modelValue: SwitchValueType;
disabled?: boolean;
activeText?: string;
inactiveText?: string;
activeValue?: SwitchValueType;
inactiveValue?: SwitchValueType;
name?: string;
id?: string;
size?: "small" | "large";
}

export interface SwitchEmits {
(e: "update:modelValue", value: SwitchValueType): void;
(e: "change", value: SwitchValueType): void;
}

export interface SwitchInstance {
focus(): void;
checked: ComputedRef<boolean>;
}

├─ Tooltip

hover文字提示, 显示额外的信息

–API–

├─ index.ts

1
2
3
4
5
6
import Tooltip from "./Tooltip.vue";
import { withInstall } from "@eric-ui/utils";

export const ErTooltip = withInstall(Tooltip);

export * from "./types";

├─ style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
.er-tooltip {
--er-popover-bg-color: var(--er-bg-color-overlay);
--er-popover-font-size: var(--er-font-size-base);
--er-popover-border-color: var(--er-border-color-lighter);
--er-popover-padding: 12px;
--er-popover-border-radius: 4px;
display: inline-block;
}
.er-tooltip {
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--er-transition-duration);
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

.er-tooltip__popper {
background: var(--er-popover-bg-color);
border-radius: var(--er-popover-border-radius);
border: 1px solid var(--er-popover-border-color);
padding: var(--er-popover-padding);
color: var(--er-text-color-regular);
line-height: 1.4;
text-align: justify;
font-size: var(--er-popover-font-size);
box-shadow: var(--er-box-shadow-light);
word-break: break-all;
box-sizing: border-box;
z-index: 1000;
#arrow,
#arrow::before {
position: absolute;
width: 8px;
height: 8px;
box-sizing: border-box;
background: var(--er-popover-bg-color);
}
#arrow {
visibility: hidden;
}
#arrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
&[data-popper-placement^='top'] > #arrow {
bottom: -5px;
}

&[data-popper-placement^='bottom'] > #arrow {
top: -5px;
}

&[data-popper-placement^='left'] > #arrow {
right: -5px;
}

&[data-popper-placement^='right'] > #arrow {
left: -5px;
}
&[data-popper-placement^='top'] > #arrow::before {
border-right: 1px solid var(--er-popover-border-color);
border-bottom: 1px solid var(--er-popover-border-color);
}
&[data-popper-placement^='bottom'] > #arrow::before {
border-left: 1px solid var(--er-popover-border-color);
border-top: 1px solid var(--er-popover-border-color);
}
&[data-popper-placement^='left'] > #arrow::before {
border-right: 1px solid var(--er-popover-border-color);
border-top: 1px solid var(--er-popover-border-color);
}
&[data-popper-placement^='right'] > #arrow::before {
border-left: 1px solid var(--er-popover-border-color);
border-bottom: 1px solid var(--er-popover-border-color);
}
}
}

├─ Tooltip.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import { describe, test,it, expect, vi, beforeEach } from "vitest";
import { withInstall } from "@eric-ui/utils";
import { mount } from "@vue/test-utils";
import { ErTooltip } from ".";

import Tooltip from "./Tooltip.vue";

vi.mock("@popperjs/core");

const onVisibleChange = vi.fn();

describe("Tooltip/index.ts", () => {
// 测试 withInstall 函数是否被正确应用
it("should be exported with withInstall()", () => {
expect(ErTooltip.install).toBeDefined();
});

// 测试 Tooltip 组件是否被正确导出
it("should be exported Tooltip component", () => {
expect(ErTooltip).toBe(Tooltip);
});

// 可选:测试 withInstall 是否增强了 Tooltip 组件的功能
test("should enhance Tooltip component", () => {
const enhancedTooltip = withInstall(Tooltip);
expect(enhancedTooltip).toBe(ErTooltip);
// 这里可以添加更多测试,确保 withInstall 增强了组件的特定功能
});

// 可选:如果你的 withInstall 函数有特定的行为或属性,确保它们被正确应用
test("should apply specific enhancements", () => {
const enhancedTooltip = withInstall(Tooltip);
// 例如,如果你的 withInstall 增加了一个特定的方法或属性
expect(enhancedTooltip).toHaveProperty("install");
});
});

describe("Tooltip.vue", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks()
});
test("basic tooltip", async () => {
const wrapper = mount(
() => (
<div>
<div id="outside"></div>
<Tooltip
content="hello tooltip"
trigger="click"
{...{ onVisibleChange }}
>
<button id="trigger">trigger</button>
</Tooltip>
</div>
),
{
attachTo: document.body,
}
);
const triggerArea = wrapper.find("#trigger");
expect(triggerArea.exists()).toBeTruthy();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();

// 弹出层是否出现
triggerArea.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
expect(wrapper.get(".er-tooltip__popper").text()).toBe("hello tooltip");
expect(onVisibleChange).toHaveBeenCalledWith(true);

// 再次点击
triggerArea.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();
expect(onVisibleChange).toHaveBeenCalledTimes(2);

// 等待动画
await vi.runAllTimers();

triggerArea.trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
// 区域外点击关闭 tooltip
wrapper.get("#outside").trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();
expect(onVisibleChange).toHaveBeenCalledTimes(4);

// 注销流程
wrapper.unmount();
});

test("tooltip with hover trigger", async () => {
// ... 省略其他设置
const wrapper = mount(Tooltip, {
props: { trigger: "hover", content: "test" },
});
// 测试悬停显示
wrapper.find(".er-tooltip__trigger").trigger("mouseenter");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
// 测试悬外隐藏
wrapper.find(".er-tooltip").trigger("mouseleave");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();
});

// 右键菜单触发的测试
test("tooltip with contextmenu trigger", async () => {
// ... 省略其他设置
const wrapper = mount(Tooltip, {
props: { trigger: "contextmenu", content: "test" },
});
// 测试右键菜单显示
wrapper.find(".er-tooltip__trigger").trigger("contextmenu");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
// 测试右键菜单隐藏(可以模拟点击外部区域)
});

// 手动模式的测试
test("tooltip with manual trigger", async () => {
// ... 省略其他设置
const wrapper = mount(Tooltip, {
props: { manual: true, content: "test" },
});
// 测试手动触发显示和隐藏
wrapper.vm.show(); // 假设 show 方法可以通过某种方式访问
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
wrapper.vm.hide();
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();
});

// 禁用状态的测试
test("disabled tooltip", async () => {
// ... 省略其他设置
const wrapper = mount(Tooltip, {
props: { disabled: true, content: "test" },
});
// 测试禁用状态下点击不会触发显示
wrapper.find(".er-tooltip__trigger").trigger("click");
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeFalsy();
});

// 虚拟触发节点的测试
test("tooltip with virtual trigger node", async () => {
// ... 省略其他设置
const virtualRef = document.createElement("div");
const wrapper = mount(Tooltip, {
props: { virtualRef, virtualTriggering: true },
});
// 测试虚拟节点的事件触发
virtualRef.dispatchEvent(new Event("mouseenter"));
await vi.runAllTimers();
expect(wrapper.find(".er-tooltip__popper").exists()).toBeTruthy();
});
});

├─ Tooltip.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
<script setup lang="ts">
import { createPopper, type Instance } from "@popperjs/core";
import { ref, watch, watchEffect, onUnmounted, computed, type Ref } from "vue";
import { bind, debounce, type DebouncedFunc } from "lodash-es";
import { useClickOutside } from "@eric-ui/hooks";

import type { TooltipProps, TooltipEmits, TooltipInstance } from "./types";
import type { ButtonInstance } from "../Button";

import useEvenstToTiggerNode from "./useEventsToTiggerNode";

defineOptions({
name: "ErTooltip",
});

interface _TooltipProps extends TooltipProps {
virtualRef?: HTMLElement | ButtonInstance | void;
virtualTriggering?: boolean;
}

const props = withDefaults(defineProps<_TooltipProps>(), {
placement: "bottom",
trigger: "hover",
transition: "fade",
showTimeout: 0,
hideTimeout: 200,
});
const emits = defineEmits<TooltipEmits>();
const visible = ref(false);

const events: Ref<Record<string, EventListener>> = ref({});
const outerEvents: Ref<Record<string, EventListener>> = ref({});
const dropdownEvents: Ref<Record<string, EventListener>> = ref({});

const containerNode = ref<HTMLElement>();
const popperNode = ref<HTMLElement>();
const _triggerNode = ref<HTMLElement>();

const triggerNode = computed(() => {
if (props.virtualTriggering)
return (
// @tips any 为了 fix 一个初始设计上的小失误 (后续重构 "虚拟目标节点" 时解决)
((props.virtualRef as ButtonInstance)?.ref as any) ??
(props.virtualRef as HTMLElement) ??
_triggerNode.value
);
return _triggerNode.value as HTMLElement;
});

const popperOptions = computed(() => ({
placement: props.placement,
modifiers: [
{
name: "offset",
options: {
offset: [0, 9],
},
},
],
...props.popperOptions,
}));

const openDelay = computed(() =>
props.trigger === "hover" ? props.showTimeout : 0
);

const closeDelay = computed(() =>
props.trigger === "hover" ? props.hideTimeout : 0
);

const triggerStrategyMap: Map<string, () => void> = new Map();
triggerStrategyMap.set("hover", () => {
events.value["mouseenter"] = openFinal;
outerEvents.value["mouseleave"] = closeFinal;
dropdownEvents.value["mouseenter"] = openFinal;
});
triggerStrategyMap.set("click", () => {
events.value["click"] = togglePopper;
});
triggerStrategyMap.set("contextmenu", () => {
events.value["contextmenu"] = (e) => {
e.preventDefault();
openFinal();
};
});

let openDebounce: DebouncedFunc<() => void> | void;
let closeDebounce: DebouncedFunc<() => void> | void;

function openFinal() {
closeDebounce?.cancel();
openDebounce?.();
}

function closeFinal() {
openDebounce?.cancel();
closeDebounce?.();
}

function togglePopper() {
visible.value ? closeFinal() : openFinal();
}

function setVisible(val: boolean) {
if (props.disabled) return;
visible.value = val;
emits("visible-change", val);
}

function attachEvents() {
if (props.disabled || props.manual) return;
triggerStrategyMap.get(props.trigger)?.();
}

let popperInstance: null | Instance;
function destroyPopperInstance() {
popperInstance?.destroy();
popperInstance = null;
}

function resetEvents() {
events.value = {};
outerEvents.value = {};
dropdownEvents.value = {};

attachEvents();
}

if (!props.manual) {
attachEvents();
}

const show: TooltipInstance["show"] = openFinal;

const hide: TooltipInstance["hide"] = function () {
openDebounce?.cancel();
setVisible(false);
};

useClickOutside(containerNode, () => {
emits("click-outside");
if (props.trigger === "hover" || props.manual) return;
visible.value && closeFinal();
});

watch(
visible,
(val) => {
if (!val) return;

if (triggerNode.value && popperNode.value) {
popperInstance = createPopper(
triggerNode.value,
popperNode.value,
popperOptions.value
);
}
},
{ flush: "post" }
);

watch(
() => props.manual,
(isManual) => {
if (isManual) {
events.value = {};
outerEvents.value = {};
dropdownEvents.value = {};
return;
}
attachEvents();
}
);

watch(
() => props.trigger,
(newTrigger, oldTrigger) => {
if (newTrigger === oldTrigger) return;
resetEvents();
}
);

watch(
() => props.disabled,
(val, oldVal) => {
if (val === oldVal) return;
openDebounce?.cancel();
visible.value = false;
emits("visible-change", false);
resetEvents();
}
);

watchEffect(() => {
openDebounce = debounce(bind(setVisible, null, true), openDelay.value);
closeDebounce = debounce(bind(setVisible, null, false), closeDelay.value);
});

useEvenstToTiggerNode(props, triggerNode, events, () => {
openDebounce?.cancel();
setVisible(false);
});

onUnmounted(() => {
destroyPopperInstance();
});

defineExpose<TooltipInstance>({
show,
hide,
});
</script>

<template>
<div class="er-tooltip" ref="containerNode" v-on="outerEvents">
<div
class="er-tooltip__trigger"
ref="_triggerNode"
v-on="events"
v-if="!virtualTriggering"
>
<slot></slot>
</div>
<slot name="default" v-else></slot>

<transition :name="transition" @after-leave="destroyPopperInstance">
<div
class="er-tooltip__popper"
ref="popperNode"
v-on="dropdownEvents"
v-if="visible"
>
<slot name="content">
{{ content }}
</slot>
<div id="arrow" data-popper-arrow></div>
</div>
</transition>
</div>
</template>

<style scoped>
@import "./style.css";
</style>

├─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { Placement, Options } from "@popperjs/core";

export interface TooltipProps {
content?: string;
trigger?: "hover" | "click" | "contextmenu";
placement?: Placement;
manual?: boolean;
disabled?: boolean;
popperOptions?: Partial<Options>;
transition?: string;
showTimeout?: number;
hideTimeout?: number;
}

export interface TooltipEmits {
(e: "visible-change", value: boolean): void;
(e: "click-outside"): void;
}

export interface TooltipInstance {
show(): void;
hide(): void;
}

└─ useEventsToTiggerNode.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { each, isElement } from "lodash-es";
import { onMounted, onUnmounted, watch } from "vue";
import type { ComputedRef, Ref, WatchStopHandle } from "vue";
import type { TooltipProps } from "./types";

export function useEvenstToTiggerNode(
props: TooltipProps & { virtualTriggering?: boolean },
triggerNode: ComputedRef<HTMLElement | undefined>,
events: Ref<Record<string, EventListener>>,
closeMethod: () => void
) {
let watchEventsStopHandle: WatchStopHandle | void;
let watchTriggerNodeStopHandle: WatchStopHandle | void;

const _eventHandleMap = new Map();

const _bindEventToVirtualTiggerNode = () => {
const el = triggerNode.value;
isElement(el) &&
each(events.value, (fn, event) => {
_eventHandleMap.set(event, fn);
el?.addEventListener(event as keyof HTMLElementEventMap, fn);
});
};
const _unbindEventToVirtualTiggerNode = () => {
const el = triggerNode.value;
isElement(el) &&
each(
["mouseenter", "click", "contextmenu"],
(key) =>
_eventHandleMap.has(key) &&
el?.removeEventListener(key, _eventHandleMap.get(key))
);
};

onMounted(() => {
watchTriggerNodeStopHandle = watch(
triggerNode,
() => props.virtualTriggering && _bindEventToVirtualTiggerNode(),
{ immediate: true }
);

watchEventsStopHandle = watch(
events,
() => {
if (!props.virtualTriggering) return;
_unbindEventToVirtualTiggerNode();
_bindEventToVirtualTiggerNode();
closeMethod();
},
{ deep: true }
);
});

onUnmounted(() => {
watchTriggerNodeStopHandle?.();
watchEventsStopHandle?.();
});
}

export default useEvenstToTiggerNode;

├─ Upload

上传组件,用于上传文件

–API–

├─ index.ts

1
2
3
4
5
6
import Upload from "./Upload.vue";
import { withInstall } from "@eric-ui/utils";

export const ErUpload = withInstall(Upload);

export * from "./types";

├─ types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export type UploadFileStatus = "ready" | "uploading" | "success" | "error";

export interface UploadFile {
uid: string;
size: number;
name: string;
status?: UploadFileStatus;
percent?: number;
raw?: File;
response?: any;
error?: any;
}

export interface UploadProps {
action: string;
defaultFileList?: UploadFile[];
beforeUpload?(file: File): boolean | Promise<File>;
onChange?(file: File): void;
onProgress?(percentage: number, file: File): void;
onSuccess?(data: any, file: File): void;
onError?(err: any, file: File): void;

onRemove?(file: UploadFile): void;
}

export interface UploadListProps {
fileList: UploadFile[];
onRemove?(file: UploadFile): void;
}

├─ Upload.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
<script setup lang="ts">
import type { UploadProps, UploadFile } from "./types";
import ErButton from "../Button/Button.vue";
import { ref } from "vue";
import { cloneDeep, each, isFunction } from "lodash-es";
import axios, { type AxiosProgressEvent } from "axios";
import { useId } from "@eric-ui/hooks";
import UploadList from "./UploadList.vue";

defineOptions({
name: "ErUpload",
});

const props = defineProps<UploadProps>();

const fileInputRef = ref<HTMLInputElement>();
const fileList = ref<UploadFile[]>(props.defaultFileList ?? []);

function setFileListItem(item: UploadFile) {
const index = fileList.value.findIndex((file) => file.uid === item.uid);
if (index !== -1) {
fileList.value.splice(index, 1, cloneDeep(item));
} else {
fileList.value.unshift(cloneDeep(item));
}
}

function handleClick() {
fileInputRef.value?.click();
}

function handleFileChange(e: Event) {
const files = (e.target as HTMLInputElement)?.files;
if (!files) return;
uploadFiles(files);
if (fileInputRef.value) {
fileInputRef.value.value = "";
}
}

function uploadFiles(files: FileList) {
const postFiles = Array.from(files);
each(postFiles, (file) => {
if (!props?.beforeUpload) {
post(file);
} else {
const result = props.beforeUpload(file);
if (result && result instanceof Promise) {
result.then((processFile) => {
post(processFile);
});
} else if (result !== false) {
post(file);
}
}
});
}

function post(file: File) {
const _file: UploadFile = {
uid: useId().value + "_upload-file_" + Date.now(),
status: "ready",
name: file.name,
size: file.size,
percent: 0,
raw: file,
};

setFileListItem(_file);

const formData = new FormData();
formData.append(file.name, file);
axios
.postForm(props.action, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (e: AxiosProgressEvent) => {
let percent = Math.round((e.loaded * 100) / e.total!) || 0;

_file.percent = percent;
if (percent < 100) {
_file.status = "uploading";
setFileListItem(_file);
}
isFunction(props.onProgress) && props.onProgress(percent, file);
},
})
.then((resp: any) => {
_file.status = "success";
_file.response = resp.data;
isFunction(props.onSuccess) && props.onSuccess(resp.data, file);
setFileListItem(_file);
})
.catch((err) => {
_file.status = "error";
isFunction(props.onError) && props.onError(err, file);
setFileListItem(_file);
})
.finally(() => {
isFunction(props.onChange) && props.onChange(file);
});
}

function handleRemove(file: UploadFile) {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
isFunction(props.onRemove) && props.onRemove(file);
}
</script>

<template>
<div class="er-upload">
<div class="er-upload__content" @click="handleClick">
<slot>
<er-button type="primary">Upload File</er-button>
</slot>
</div>
<input
ref="fileInputRef"
class="er-file-input"
type="file"
@change="handleFileChange"
style="display: none"
/>
<upload-list :file-list="fileList" :on-remove="handleRemove" />
</div>
</template>

└─ UploadList.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<script setup lang="ts">
import type { UploadListProps } from "./types";
import ErIcon from "../Icon/Icon.vue";
defineOptions({
name: "ErUploadList",
});

defineProps<UploadListProps>();

const statusIconMap = new Map([
["success", "check-circle"],
["error", "times-circle"],
["uploading", "spinner"],
]);
</script>

<template>
<ul class="er-upload-list">
<li v-for="item in fileList" :key="item.uid" class="er-upload-list__item">
<span class="file-name" :class="{ [`file-name-${item.status}`]: true }">
<er-icon icon="file-alt" />
{{ item.name }}
</span>
<span
class="file-status"
:class="{ [`file-status-${item.status}`]: true }"
>
<er-icon
:icon="statusIconMap.get(item.status || '') ?? ''"
:spin="item.status === 'uploading'"
/>
</span>
<span class="file-action">
<er-icon icon="times" @click="onRemove?.(item)" />
</span>
</li>
</ul>
</template>

<style scoped>
.er-upload-list {
margin: 0;
padding: 0;
list-style-type: none;
.er-upload-list__item {
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
font-size: 14px;
line-height: 1.8;
margin-top: 5px;
box-sizing: border-box;
border-radius: 4px;
min-width: 200px;
position: relative;
&:first-child {
margin-top: 10px;
}
.file-name {
margin-left: 5px;
margin-right: 40px;
color: var(--er-text-color-regular);
svg {
margin-right: 5px;
color: var(--er-text-color-regular);
}
}
.file-name-error {
color: var(--er-color-danger);
}
.file-status-success {
color: var(--er-color-success);
}
.file-status-error {
color: var(--er-color-danger);
}
.file-status {
display: block;
position: absolute;
right: 5px;
top: 0;
line-height: inherit;
}
.file-action {
display: none;
position: absolute;
right: 7px;
top: 0;
line-height: inherit;
cursor: pointer;
}
&:hover {
background-color: var(--er-fill-color-light);
.file-status {
display: none;
}
.file-action {
display: block;
}
}
}
}
</style>

├─ index.test.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import type { Plugin } from "vue";
import { describe, it, expect } from "vitest";
import {
ErAlert,
ErButton,
ErButtonGroup,
ErCollapse,
ErCollapseItem,
ErDropdown,
ErDropdownItem,
ErForm,
ErFormItem,
ErIcon,
ErInput,
ErLoading,
ErLoadingDirective,
ErLoadingService,
ErMessage,
ErMessageBox,
ErNotification,
ErOption,
ErPopconfirm,
ErSelect,
ErSwitch,
ErTooltip,
ErUpload,
} from "./index";
import { map, get } from "lodash-es";

const components = [
ErButton,
ErButtonGroup,
ErCollapse,
ErCollapseItem,
ErIcon,
ErDropdown,
ErDropdownItem,
ErTooltip,
ErMessage,
ErInput,
ErSwitch,
ErSelect,
ErOption,
ErForm,
ErFormItem,
ErAlert,
ErNotification,
ErLoading,
ErUpload,
ErPopconfirm,
ErMessageBox,
] as Plugin[];

describe("components/index.ts", () => {
it.each(map(components, (c) => [get(c, "name") ?? "", c]))("%s should be exported", (_, component) => {
expect(component).toBeDefined();
expect(component.install).toBeDefined()
});

it('ErLoadingService and ErLoadingDirective should be exported',()=>{
expect(ErLoadingService).toBeDefined()
expect(ErLoadingDirective).toBeDefined()
})
});

├─ index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export * from './ConfigProvider'
export * from "./Icon";
export * from "./Button";
export * from "./Collapse";
export * from "./Dropdown";
export * from "./Message";
export * from "./Tooltip";
export * from "./Input";
export * from "./Switch";
export * from "./Select";
export * from "./Form";
export * from "./Alert";
export * from "./Notification";
export * from "./Loading";
export * from "./Upload";
export * from "./Popconfirm";
export * from "./MessageBox";

└─ package.json

项目依赖

开发依赖

  • axios:HTTP 客户端(用于测试或示例)。

├─ constants

常量系统

├─ index.ts

├─ key.ts

└─ package.json

项目依赖

空包

开发依赖

axios:HTTP 客户端(用于测试或示例)。

├─ core

核心包托管模式

npm包的入口,同时作为 components 的唯一出口

负责聚合所有子模块(如 components, hooks, theme, utils 等)的功能,并提供给外部使用

├─ build

构建入口

放vite的两个配置文件

├─ vite.es.config.ts

ES Module 构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import { defineConfig } from "vite";
import { readdirSync, readdir } from "fs";
import { resolve } from "path";
import { defer, delay, filter, map, includes } from "lodash-es";
import { visualizer } from "rollup-plugin-visualizer";
import { hooksPlugin as hooks } from "@tyche/vite-plugins";
import shell from "shelljs";

import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
import terser from "@rollup/plugin-terser";

const TRY_MOVE_STYLES_DELAY = 750 as const;

const isProd = process.env.NODE_ENV === "production";
const isDev = process.env.NODE_ENV === "development";
const isTest = process.env.NODE_ENV === "test";

function getDirectoriesSync(basePath: string) {
const entries = readdirSync(basePath, { withFileTypes: true });

return map(
filter(entries, (entry) => entry.isDirectory()),
(entry) => entry.name
);
}

function moveStyles() { //在构建完成后,将 dist/es/theme 目录移动到 dist 目录
readdir("./dist/es/theme", (err) => {
if (err) return delay(moveStyles, TRY_MOVE_STYLES_DELAY);
defer(() => shell.mv("./dist/es/theme", "./dist"));//使用 shell.mv 命令执行移动操作,通过 defer 和 delay 处理异步操作
});
}

export default defineConfig({
plugins: [
vue(),//支持 Vue 3 的单文件组件
visualizer({//生成构建性能分析报告(dist/stats.es.html
filename: "dist/stats.es.html",
}),
dts({//生成 TypeScript 类型定义文件(dist/types/core/index.d.ts)
tsconfigPath: "../../tsconfig.build.json",
outDir: "dist/types",
}),
terser({//压缩代码(仅生产环境启用)
compress: {
sequences: isProd,
arguments: isProd,
drop_console: isProd && ["log"],
drop_debugger: isProd,
passes: isProd ? 4 : 1,
global_defs: {
"@DEV": JSON.stringify(isDev),
"@PROD": JSON.stringify(isProd),
"@TEST": JSON.stringify(isTest),
},
},
format: {
semicolons: false,
shorthand: isProd,
braces: !isProd,
beautify: !isProd,
comments: !isProd,
},
mangle: {
toplevel: isProd,
eval: isProd,
keep_classnames: isDev,
keep_fnames: isDev,
},
}),
hooks({//自定义构建钩子,如清理文件和移动 CSS 文件
rmFiles: [
"./dist/es",
"./dist/theme",
"./dist/types",
"./dist/stats.es.html",
],
afterBuild: moveStyles,
}),
],
build: {
outDir: "dist/es",//输出目录为 dist/es
minify: false,
cssCodeSplit: true,
sourcemap: !isProd,
lib: {//配置库的
entry: resolve(__dirname, "../index.ts"),//入口文件
name: "EricUI",//名称
fileName: "index",//文件名
formats: ["es"],//格式
},
rollupOptions: {
external: [//声明外部依赖,避免打包到最终输出中。
"vue",
"@fortawesome/fontawesome-svg-core",
"@fortawesome/free-solid-svg-icons",
"@fortawesome/vue-fontawesome",
"@popperjs/core",
"async-validator",
],
output: {
assetFileNames: (chunkInfo) => {
if (chunkInfo.name === "style.css") {
return "index.css";
}
if (
chunkInfo.type === "asset" &&
/\.(css)$/i.test(chunkInfo.name as string)
) {
return "theme/[name].[ext]";
}
return chunkInfo.name as string;
},
manualChunks(id) {//手动分块策略
if (includes(id, "node_modules")) return "vendor";//第三方库
if (includes(id, "/packages/hooks")) return "hooks";//自定义钩子
if (includes(id, "/packages/utils") || includes(id, "plugin-vue:export-helper")) return "utils";//工具函数
for (const item of getDirectoriesSync("../components")) {
//组件目录(如 components/xxx) → 对应组件名称的分块
if (includes(id, `/packages/components/${item}`)) return item;
}
},
},
},
},
});

└─ vite.umd.config.ts

UMD 构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import { defineConfig } from "vite";
import { readFile } from "fs";//同步读取(文件)
import { resolve } from "path";
import { defer, delay } from "lodash-es";
import { visualizer } from "rollup-plugin-visualizer";
import { hooksPlugin as hooks } from "@tyche/vite-plugins";
import shell from "shelljs";
import vue from "@vitejs/plugin-vue";
import compression from "vite-plugin-compression";
import terser from "@rollup/plugin-terser";

const TRY_MOVE_STYLES_DELAY = 750 as const;

const isProd = process.env.NODE_ENV === "production";
const isDev = process.env.NODE_ENV === "development";
const isTest = process.env.NODE_ENV === "test";

function moveStyles() {
readFile("./dist/umd/index.css.gz", (err) => {
if (err) return delay(moveStyles, TRY_MOVE_STYLES_DELAY);
defer(() => shell.cp("./dist/umd/index.css", "./dist/index.css"));
});
}

export default defineConfig({
plugins: [
vue(),
compression({
filter: /.(cjs|css)$/i,
}),
visualizer({
filename: "dist/stats.umd.html",
}),
terser({
compress: {
drop_console: ["log"],
drop_debugger: true,
passes: 3,
global_defs: {
"@DEV": JSON.stringify(isDev),
"@PROD": JSON.stringify(isProd),
"@TEST": JSON.stringify(isTest),
},
},
}),
hooks({
rmFiles: ["./dist/umd", "./dist/index.css", "./dist/stats.umd.html"],
afterBuild: moveStyles,
}),
],
build: {
outDir: "dist/umd",
lib: {
entry: resolve(__dirname, "../index.ts"),
name: "EricUI",
fileName: "index",
formats: ["umd"],
},
rollupOptions: {
external: ["vue"],
output: {
exports: "named",
globals: {
vue: "Vue",
},
assetFileNames: (chunkInfo) => {
if (chunkInfo.name === "style.css") {
return "index.css";
}
return chunkInfo.name as string;
},
},
},
},
});

├─ componens.ts

集中导出组件

1
2
3
4
import { ErButton, ErButtonGroup } from '@tyche/components'
import type { Plugin } from 'vue'

export default [ErButton, ErButtonGroup] as Plugin[]

将组件注册为 Vue 插件,用户通过 app.use(Tyche) 可以一次性安装所有组件。

├─ index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { makeInstaller } from '@tyche/utils'
import components from './components'
import printLogo from "./pringLogo";
import '@tyche/theme/index.css'

printLogo();

library.add(fas);
const installer = makeInstaller(components);

export * from '@tyche/components'
export default installer

├─ makeInstaller.ts

全局注册+构建配置

插件安装器:将组件集合转换为 Vue 插件,并支持全局配置(如主题、国际化)

1
2
3
4
5
6
7
8
9
export default function makeInstaller(components: Plugin[]) {
const install = (app: App, options?: ConfigProviderProps) => {
if (get(app, INSTALLED_KEY)) return;
set(app, INSTALLED_KEY, true);
each(components, (c) => app.use(c));
if (options) provideGlobalConfig(options, app, true);
};
return install;
}
  • 防止重复安装插件。
  • 通过 app.use 注册所有组件。
  • 支持全局配置(如通过 provideGlobalConfig 提供主题或默认选项)。

├─ package.json

项目依赖

  • @fortawesome/fontawesome-svg-core
  • @fortawesome/free-solid-svg-icons
  • @fortawesome/vue-fontawesome
  • @popperjs/core
  • async-validator

开发依赖

  • @rollup/plugin-terser:Rollup 压缩插件
  • rollup-plugin-visualizer:打包分析插件
  • vite-plugin-compression:文件压缩插件

项目元数据与依赖管理:定义构建目标、入口文件、依赖关系等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
"name": "tyche",
"version": "1.0.0",
"description": "使用 Vue3 + Ts 构建的组件库",
"type": "module",//指定使用 ES 模块
"files": [
"dist"
],
"sideEffects": [
"./dist/index.css",
"./dist/theme/*.css"
],
"main": "./dist/umd/index.umd.cjs",//CommonJS 格式的入口文件
"module": "./dist/es/index.js",//ESM 格式的入口文件
"types": "./dist/types/core/index.d.ts",//TypeScript 类型定义文件的位置
"exports": {//定义模块导出路径
".": {//分别指向不同环境的入口文件
"import": "./dist/es/index.js", //ESM 入口
"require": "./dist/umd/index.umd.cjs", //CommonJS 入口
"types": "./dist/types/core/index.d.ts" //TypeScript 类型定义
},
"./dist/": {//额外指定了对 dist 目录下资源的导入规则
"import": "./dist/",
"require": "./dist/"
}
},
"scripts": {
"build": "run-p build-es build-umd",
"build:watch": "run-p build-es:watch build-umd:watch",
"build-es": "vite build --config build/vite.es.config.ts",
"build-umd": "vite build --config build/vite.umd.config.ts",
"build-es:watch": "vite build --watch --config build/vite.es.config.ts",
"build-umd:watch": "vite build --watch --config build/vite.umd.config.ts"
},
"keywords": [],
"author": "Breezli",
"license": "ISC",//许可证类型为 ISC,一个简化的开源许可证
"devDependencies": {//开发环境
"@rollup/plugin-terser": "^0.4.4",//Rollup 插件,用于压缩代码
"rollup-plugin-visualizer": "^5.12.0",//提供构建结果的可视化分析
"terser": "^5.31.0",//JavaScript 解析器和压缩工具
"vite-plugin-compression": "^0.5.1"//Vite 插件,用于压缩资源文件
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",//FontAwesome 图标集
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@popperjs/core": "^2.11.8",//Popper.js 核心库,通常用于定位提示框、菜单等元素
"async-validator": "^4.2.5"//异步表单验证库
},
"peerDependencies": {
"vue": "^3.4.27"
}
}

/// 补充:peerDependencies ///

1
2
3
4
5
6
7
8
9
├── helloWorld
│ └── node_modules
│ ├── packageA
│ ├── plugin1
│ │ └── nodule_modules
│ │ └── packageA
│ └── plugin2
│ │ └── nodule_modules
│ │ └── packageA

packageA 核心依赖库被重复下载

此时我们应该在

plugin1 /package.json & plugin2 /package.json

1
2
3
4
5
{
"peerDependencies": {
"packageA": "1.0.1"
}
}

/package.json

1
2
3
4
5
{
"dependencies": {
"packageA": "1.0.1"
}
}

结构就变成了

1
2
3
4
5
├── helloWorld
│ └── node_modules
│ ├── packageA
│ ├── plugin1
│ └── plugin2

通常用于库或插件,这些库或插件不直接包含在最终应用中,但需要与其他包协同工作

当你安装一个包时,如果该包有peerDependencies,npm或yarn会检查这些依赖是否已经安装在项目中,如果没有,它会给出警告,但不会默认安装它们

└─ pringLogo.ts

构建时输出 Logo 和提示信息:在生产环境构建时打印彩色 Logo,在开发环境提示当前模式。

1
2
3
4
5
6
7
8
9
10
11
export default function () {
if (PROD) { // 项目正在`生产环境`中运行
const logo = `...`; // 彩色渐变的 EricUI Logo 文字
const rainbowGradient = `...`; // CSS 样式
console.info(`%c${logo}`, rainbowGradient);
//%c 是占位符,表示接下来的一个参数 (rainbowGradient) 应该被解释为CSS样式。
//${logo} 是实际要打印到控制台的文字内容(即那个ASCII艺术风格的logo)。
} else if (DEV) { // 项目处于`开发模式`下
console.log("[EricUI]:dev mode..."); // 简单地向控制台输出一条 "[EricUI]:dev mode..."。有助于开发者快速识别项目的当前状态是处于开发阶段
}
}

├─ docs

组件库项目文档,vitepress站点

├─ .vitepress

├─ config.ts

└─ theme

└─ index.ts

├─ components

├─ alert.md

├─ button.md

├─ collapse.md

├─ dropdown.md

├─ form.md

├─ loading.md

├─ message.md

├─ messagebox.md

├─ notification.md

├─ popconfirm.md

└─ tooltip.md

├─ demo

├─ alert

├─ Basic.vue
├─ Close.vue
├─ Desc.vue
├─ IconDesc.vue
├─ ShowIcon.vue
├─ TextCenter.vue
└─ Theme.vue

├─ button

├─ Basic.vue
├─ Disabled.vue
├─ Group.vue
├─ Icon.vue
├─ Loading.vue
├─ Size.vue
├─ Tag.vue
└─ Throttle.vue

├─ collapse

├─ Accordion.vue
├─ Basic.vue
├─ CustomTitle.vue
└─ Disabled.vue

│ │ │ ├─ dropdown

├─ Basic.vue
├─ Command.vue
├─ Disabled.vue
├─ HideOnClick.vue
├─ InstanceMethod.vue
├─ Size.vue
├─ SplitButton.vue
└─ Trigger.vue

├─ form

├─ Basic.vue
├─ CustomValidate.vue
├─ Position.vue
└─ Validate.vue

├─ loading

├─ Basic.vue
├─ Custom.vue
└─ Fullscreen.vue

├─ message

├─ Basic.vue
├─ Center.vue
├─ Closeable.vue
├─ Test.vue
└─ Type.vue

├─ messagebox

├─ Alert.vue
├─ Center.vue
├─ Confirm.vue
├─ Custom.vue
├─ Prompt.vue
└─ VNode.vue

├─ notification

├─ Basic.vue
├─ Closeable.vue
└─ Type.vue

├─ popconfirm

├─ Basic.vue
├─ Callback.vue
└─ Custom.vue

└─ tooltip

├─ Basic.vue
├─ Disabled.vue
└─ Slot.vue

├─ get-started.md

├─ index.md

└─ package.json

依赖(dependencies)

(通过根目录继承)。

开发依赖(devDependencies)

  • @vitepress-demo-preview/component:VitePress 组件预览插件
  • @vitepress-demo-preview/plugin:VitePress 插件
  • vitepress:文档框架

├─ hooks

钩子函数, 自定义的 Vue 组合式 API

逻辑复用, 用于处理各种功能需求

├─ index.ts

hooks 的入口文件,导出所有 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import useClickOutside from "./useClickOutside";
import useEventListener from "./useEventListener";
import useFocusController from "./useFocusController";
import useZIndex from "./useZIndex";
import useProp from "./useProp";
import useDisabledStyle from "./useDisabledStyle";
import useId from "./useId";
import useLocale from "./useLocale";
import useOffset from "./useOffset";

export {
useClickOutside,
useEventListener,
useZIndex,
useProp,
useFocusController,
useDisabledStyle,
useLocale,
useOffset,
useId,
};

├─ package.json

依赖

peerDependencies(必须由用户安装)

  • vue:Vue 3 核心库
  • lodash-es:Lodash 的 ES 模块版本

├─ useClickOutside.ts

监听用户是否点击了某个 DOM 元素之外的区域

实现 下拉菜单弹窗 等需要点击外部关闭的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { type Ref } from 'vue'
import useEventListener from './useEventListener'

export default function useClickOutside (
elementRef: Ref<HTMLElement | void>,
callback: (e: MouseEvent) => void
) {
useEventListener(document, 'click', (e: Event) => {
if (elementRef.value && e.target) {
if (!elementRef.value.contains(e.target as HTMLElement)) {
callback(e as MouseEvent)
}
}
})
}

├─ useDisabledStyle.ts

动态控制组件的禁用样式

可以根据 disabled 属性动态添加或移除样式类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { each, isFunction, cloneDeep, assign } from "lodash-es";
import { watchEffect, useSlots, getCurrentInstance, type VNode } from "vue";

const _dfs = (nodes: VNode[], cb: (node: VNode) => void) =>
each(nodes, (node) => {
isFunction(cb) && cb(node);
node.children && _dfs(node.children as VNode[], cb);
});

export function useDisabledStyle() {
const nodePropsMap = new Map();

const instance = getCurrentInstance()!;
const children = useSlots()?.default?.();

watchEffect(() => {
if (!instance.props?.disabled) {
_dfs(children ?? [], (node) => {
if (!nodePropsMap.has(node)) return;
node.props = nodePropsMap.get(node);
});
return;
}
_dfs(children ?? [], (node) => {
if (!node?.props) return;

nodePropsMap.set(node, cloneDeep(node.props));
node.props = assign(node?.props, {
style: {
cursor: "not-allowed",
color: "var(--er-text-color-placeholder)",
},
});
});
});
}

export default useDisabledStyle;

├─ useEventListener.ts

封装了一个通用的 事件监听器

支持在组件生命周期内

自动绑定和解绑事件

避免内存泄漏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {
onMounted,
onBeforeUnmount,
watch,
isRef,
unref,
type MaybeRef,
} from "vue";

export default function useEventListener(
target: MaybeRef<EventTarget | HTMLElement | void>,
event: string,
handler: (e: Event) => any
) {
if (isRef(target)) {
watch(target, (val, oldVal) => {
oldVal?.removeEventListener(event, handler);
val?.addEventListener(event, handler);
});
} else {
onMounted(() => target?.addEventListener(event, handler));
}

onBeforeUnmount(() => unref(target)?.removeEventListener(event, handler));
}

├─ useFocusController.ts

对焦点的管理功能

聚焦某个元素或在多个元素之间切换焦点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { isFunction } from "lodash-es";
import { getCurrentInstance, ref, type Ref } from "vue";
import useEventListener from "./useEventListener";

interface UseFocusControllerOptions {
afterFocus?(): void;
beforeBlur?(event: FocusEvent): boolean | void;
afterBlur?(): void;
}

export function useFocusController<T extends HTMLElement | { focus(): void }>(
target: Ref<T | void>,
{ afterFocus, beforeBlur, afterBlur }: UseFocusControllerOptions = {}
) {
const instance = getCurrentInstance()!;
const { emit } = instance;
const wrapperRef = ref<HTMLElement>();
const isFocused = ref(false);

const handleFocus = (event: FocusEvent) => {
if (isFocused.value) return;
isFocused.value = true;
emit("focus", event);
afterFocus?.();
};

const handleBlur = (event: FocusEvent) => {
const cancelBlur = isFunction(beforeBlur) ? beforeBlur(event) : false;
if (
cancelBlur ||
(event.relatedTarget &&
wrapperRef.value?.contains(event.relatedTarget as Node))
)
return;

isFocused.value = false;
emit("blur", event);
afterBlur?.();
};

const handleClick = () => {
target.value?.focus();
};

useEventListener(wrapperRef, "click", handleClick);

return {
wrapperRef,
isFocused,
handleFocus,
handleBlur,
};
}

export default useFocusController;

├─ useId.ts

生成唯一的 ID

通常用于组件内部需要唯一标识符的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { type Ref, computed } from "vue";

const defaultIdInjection = {
prefix: Math.floor(Math.random() * 10000),
current: 0,
};

export function useId(namespace: string = "er"): Ref<string> {
const idRef = computed(
() =>
`${namespace}-id-${
defaultIdInjection.prefix
}-${defaultIdInjection.current++}`
);

return idRef;
}

export default useId;

├─ useLocale.ts

提供国际化(i18n)支持

动态获取当前语言环境下的文本内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { inject, type Ref } from "vue";
import { omit } from "lodash-es";
import { createI18n, i18nSymbol, type I18nInstance } from "vue3-i18n";
import type { Language } from "@eric-ui/locale";
import English from "@eric-ui/locale/lang/en";

export function useLocale(localeOverrides?: Ref<Language>) {
if (!localeOverrides) {
return omit(
<I18nInstance>(
inject(
i18nSymbol,
createI18n({ locale: English.name, messages: { en: English.el } })
)
),
"install"
);
}

return omit(
createI18n({
locale: localeOverrides.value.name,
messages: {
en: English.el,
[localeOverrides.value.name]: localeOverrides.value.el,
},
}),
"install"
);
}

export default useLocale;

├─ useOffset.ts

计算某个 DOM 元素相对于视口或其他父级元素的偏移量

实现 悬浮层、**弹出框 **等需要精确定位的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { type Ref, computed } from "vue";

interface useOffsetOptions {
// id: string;
offset: number;
boxHeight: Ref<number>;
getLastBottomOffset: () => number;
}

interface useOffsetResult {
topOffset: Ref<number>;
bottomOffset: Ref<number>;
}

export function useOffset(opts: useOffsetOptions): useOffsetResult {
// 上一个实例最下面的坐标,第一个是0
const lastBottomOffset = computed(() => opts.getLastBottomOffset());
// 本元素应该的 top
const topOffset = computed(() => opts.offset + lastBottomOffset.value);
// 为下一个实例预留的底部 offset
const bottomOffset = computed(() => topOffset.value + opts.boxHeight.value);

return {
topOffset,
bottomOffset,
};
}

export default useOffset;

├─ useProp.ts

封装了对组件 props 的处理逻辑

提供更安全的默认值和类型检查

1
2
3
4
5
6
7
8
9
import { computed, getCurrentInstance, type ComputedRef } from "vue";

export default function useProp<T>(propName: string): ComputedRef<T | void> {
const instance = getCurrentInstance();
if (!instance) {
throw new Error("useProp must be used within a component");
}
return computed(() => (instance?.proxy?.$props as any)?.[propName] as T);
}

├─ useZIndex.ts

管理弹窗的 z-index层级,确保组件的层级关系正确

弹窗、**加载遮罩 **等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { computed, ref } from "vue";

const zIndex = ref(0);
export default function useZIndex(initialValue = 2000) {
const _initialValue = ref(initialValue);
const currentZindex = computed(() => zIndex.value + _initialValue.value);

const nextZIndex = () => {
zIndex.value++;
return currentZindex.value;
};

return {
initialValue: _initialValue,
currentZindex,
nextZIndex,
};
}

├─ vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { resolve } from "path";
import { defineConfig } from "vite";
import { last, split, first, includes } from "lodash-es";
import { hooksPlugin as hooks } from "@eric-ui/vite-plugins";

import dts from "vite-plugin-dts";

export default defineConfig({
plugins: [
dts({
include: ["./**/*.ts"],
exclude: ["./vite.config.ts"],
}),
hooks({
rmFiles: ["./dist"],
}),
],
build: {
minify: false,
lib: {
entry: resolve(__dirname, "./index.ts"),
name: "hooks",
fileName: "index",
formats: ["es"],
},
rollupOptions: {
external: ["vue", "lodash-es", "vue3-i18n"],
output: {
manualChunks(id) {
if (includes(id, "/packages/hooks/use"))
return first(split(last(split(id, "/")), "."));
},
},
},
},
});

└─ test

测试文件

├─ index.test.tsx

hooks 入口文件的单元测试

1
2
3
4
5
6
7
8
9
10
11
import { describe, expect, it } from "vitest";
import {useClickOutside,useEventListener} from '..'

describe("hooks/index", () => {
it('useEventListener should be exported',()=>{
expect(useEventListener).toBeDefined()
})
it('useClickOutside should be exported',()=>{
expect(useClickOutside).toBeDefined()
})
});

├─ useClickOutset.test.tsx

useClickOutside 的单元测试

验证其点击外部区域的行为是否符合预期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { describe, expect, it, vi } from "vitest";
import { ref, defineComponent } from "vue";
import { mount } from "@vue/test-utils";

import useClickOutside from "../useClickOutside";

describe("hooks/useClickOutside", () => {
it('should add "click-outside" listener', async () => {
const target = ref<HTMLElement | void>();
const btnRef = ref<HTMLButtonElement | void>();

const handler = vi.fn();

mount(
defineComponent({
setup() {
useClickOutside(target, handler);
return () => (
<div ref={target}>
<button ref={btnRef}>click me</button>
</div>
);
},
})
);

await btnRef.value?.click();
expect(handler).not.toHaveBeenCalled();

await document.body.click();
expect(handler).toHaveBeenCalled();
});
});

└─ useEventListener.test.tsx

useEventListener 的单元测试

验证事件监听器的绑定和解绑是否正常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { describe, expect, it, vi } from "vitest";
import { ref, defineComponent } from "vue";
import { mount } from "@vue/test-utils";

import useEventListener from "../useEventListener";

describe("hooks/useEventListener", () => {
it("should add and remove event listener when target is HTMLElement", async () => {
const target = document.createElement("button");
const handler = vi.fn();
const wrapper = mount(
defineComponent({
setup() {
useEventListener(target, "click", handler);
return () => <div id="container"></div>;
},
})
);
wrapper.get("#container").element.appendChild(target);

await target.click();
expect(handler).toHaveBeenCalledOnce();

await wrapper.unmount();
await target.click();
expect(handler).toHaveBeenCalledOnce();
});
it("should add and remove event listener when target is Ref<HTMLElement>", async () => {
const target = ref<HTMLElement | void>();
const handler = vi.fn();

mount(
defineComponent({
setup() {
useEventListener(target, "click", handler);
return () => <button id="container" ref={target}></button>;
},
})
);

await document.getElementById('container')?.click()
await target.value?.click();

expect(handler).toHaveBeenCalledOnce();

target.value = document.createElement("div");
await document.getElementById('container')?.click()
expect(handler).toHaveBeenCalledOnce();
});
});

├─ locale

国际化

├─ index.ts

├─ lang

语言包

├─ en.ts

├─ ja.ts

├─ ko.ts

├─ zh-cn.ts

└─ zh-tw.ts

└─ package.json

空包

├─ play

storybook实验室

├─ .storybook

├─ main.js

└─ preview.js

├─ index.html

├─ public

└─ vite.svg

├─ src

├─ App.vue

├─ main.ts

├─ stories

├─ Alert.stories.tsx
├─ Button.stories.ts
└─ Collapse.stories.ts

└─ vite-env.d.ts

├─ vite.config.ts

├─ README.md

└─ package.json

项目依赖

无,通过根目录继承

开发依赖

  • @chromatic-com/storybook:Chromatic 截图测试工具
  • @storybook/addon-*:Storybook 的扩展插件
  • @storybook/vue3:Storybook 的 Vue 3 支持
  • @storybook/vue3-vite:Storybook 的 Vite 集成
  • @vitejs/plugin-vue:Vite 的 Vue 插件
  • chromatic:Chromatic 命令行工具
  • storybook:Storybook 核心

├─ theme

样式系统

样式构建流程集中在 core

采用 核心包托管模式

theme 包仅作为源码存在

├─ index.css

├─ package.json

项目依赖

└─ reset.css

└─ utils

工具函数和辅助方法,支持组件库的底层逻辑

├─ error.ts

错误处理相关的工具函数

例如 抛出错误捕获异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { isString } from "lodash-es";

class ErUIError extends Error {
constructor(message: string) {
super(message);
this.name = "ErUIError";
}
}

export function throwError(scope: string, msg: string) {
throw new ErUIError(`[${scope}] ${msg}`);
}

export function debugWarn(error: Error): void;
export function debugWarn(scope: string, msg: string): void;
export function debugWarn(scope: string | Error, msg?: string) {
if (process.env.NODE_ENV !== "production") {
const err = isString(scope) ? new ErUIError(`[${scope}] ${msg}`) : scope;
console.warn(err);
}
}

├─ index.ts

导出公共类型或接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { defineComponent } from "vue";
import { isFunction } from "lodash-es";

export const RenderVnode = defineComponent({
props: {
vNode: {
type: [String, Object, Function],
required: true,
},
},
setup(props) {
return () => (isFunction(props.vNode) ? props.vNode() : props.vNode);
},
});

export const typeIconMap = new Map([
["info", "circle-info"],
["success", "check-circle"],
["warning", "circle-exclamation"],
["danger", "circle-xmark"],
["error", "circle-xmark"],
]);

export * from "./install";
export * from "./error";
export * from "./style";

├─ install.ts

给install附加一些方法和对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import type { App, Plugin, Directive } from "vue";
import { noop } from "lodash-es";

type SFCWithInstall<T> = T & Plugin;


export const withInstall = <T>(component: T) => {
(component as SFCWithInstall<T>).install = (app: App) => {
const name = (component as any)?.name || "UnnamedComponent";
app.component(name, component as SFCWithInstall<T>);
};
return component as SFCWithInstall<T>;
};

export const withInstallFunction = <T>(fn: T, name: string) => {
(fn as SFCWithInstall<T>).install = (app: App) => {
app.config.globalProperties[name] = fn;
};
return fn as SFCWithInstall<T>;
};

export const withInstallDirective = <T extends Directive>(
directive: T,
name: string
): SFCWithInstall<T> => {
(directive as SFCWithInstall<T>).install = (app: App) => {
app.directive(name, directive);
};
return directive as SFCWithInstall<T>;
};

export const withNoopInstall = <T>(component: T) => {
(component as SFCWithInstall<T>).install = noop;
return component as SFCWithInstall<T>;
};

使用举例

1
2
3
export const ErButton = withInstall(Button);
export const ErButtonGroup = withInstall(ButtonGroup);
......

├─ package.json

依赖

├─ style.ts

处理动态样式或 CSS 变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { isNumber, isString } from "lodash-es";
import { debugWarn } from "./error";

const SCOPE = "utils/style" as const;

const isStringNumber = (val: string): boolean => {
if (!isString(val)) {
return false;
}
return !Number.isNaN(Number(val));
};
export function addUnit(value?: string | number, defaultUnit = "px") {
if (!value) return "";
if (isNumber(value) || isStringNumber(value)) {
return `${value}${defaultUnit}`;
}
if (isString(value)) {
return value;
}
debugWarn(SCOPE, "binding value must be a string or number");
}

└─ tests

├─ error.test.tsx

error.ts 的单元测试,验证错误处理逻辑是否正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {describe,expect,it,vi} from 'vitest'
import {debugWarn,throwError} from '../error'

describe('error',()=>{
it('throwError should work',()=>{
expect(()=>{
throwError('scope', 'message')
}).toThrowError('[scope] message')
})
it('debugWarn should work',()=>{
const warn = vi.spyOn(console, 'warn').mockImplementation(()=> vi.fn)
debugWarn('scope','message')
debugWarn(new SyntaxError('custom error'))
expect(warn.mock.calls).toMatchInlineSnapshot(`
[
[
[ErUIError: [scope] message],
],
[
[SyntaxError: custom error],
],
]
`)
})
})

├─ index.test.tsx

utils 入口文件的单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { describe, expect, it } from "vitest";
import {
debugWarn,
throwError,
withInstall,
typeIconMap,
} from "../";
import { each } from "lodash-es";

describe("utils/index", () => {
it("debugWarn should be exported", () => {
expect(debugWarn).toBeDefined();
});
it("throwError should be exported", () => {
expect(throwError).toBeDefined();
});
it("withInstall should be exported", () => {
expect(withInstall).toBeDefined();
});
it("typeIconMap should be worked", () => {
expect(typeIconMap).toBeDefined();
each(
[
["info", "circle-info"],
["success", "check-circle"],
["warning", "circle-exclamation"],
["danger", "circle-xmark"],
["error", "circle-xmark"],
],
([type, icon]) => {
expect(typeIconMap.get(type)).toBe(icon);
}
);
});
});

└─ install.test.tsx

install.ts 的单元测试,验证组件注册逻辑是否正常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { describe, expect, it } from "vitest";
import { defineComponent, createApp } from "vue";
import { mount } from "@vue/test-utils";
import { withInstall } from "../install";

const AppComp = defineComponent({
setup() {
return () => <div>app</div>;
},
});

const componentA = withInstall(
defineComponent({
name: "test",
setup() {
return () => <div>test</div>;
},
})
);

const componentB = withInstall(
defineComponent({
name: "test2",
setup() {
return () => <div>test2</div>;
},
})
);

describe("install", () => {
it("withInstall should be worked", () => {
const wapper = mount(() => <div id="app2"></div>);
const app = createApp(AppComp);

app.use(componentA).use(componentB).mount(wapper.element);

expect(componentA.install).toBeDefined();
expect(componentB.install).toBeDefined();
expect(wapper.findComponent(componentA)).toBeTruthy();
expect(wapper.findComponent(componentB)).toBeTruthy();
});
});

├─ package.json

根目录依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
{
"name": "tyche",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "pnpm --filter @tyche/play dev",
"story": "pnpm --filter @tyche/play storybook",
"docs:dev": "pnpm --filter @tyche/docs dev",
"docs:build": "pnpm --filter @tyche/docs build",
"test": "pnpm --filter @tyche/components test",
"build": "run-s build-hooks build-components",//构建 @tyche/docs 子项目的静态文档站点
"build-components": "cross-env NODE_ENV=production pnpm --filter tyche build",
"build-hooks": "cross-env NODE_ENV=production pnpm --filter @tyche/hooks build",
"build:dev": "run-s build-hooks:dev build-components:dev",
"build-components:dev": "cross-env NODE_ENV=development pnpm --filter tyche build:watch",
"build-hooks:dev": "cross-env NODE_ENV=development pnpm --filter @tyche/hooks build"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.4.1",
"devDependencies": {//开发依赖
"@types/lodash-es": "^4.17.12", //Lodash 的 TypeScript 类型定义
"@types/node": "^20.12.12", //Node.js 的 TypeScript 类型定义
"@types/shelljs": "^0.8.15", //Shell.js 的 TypeScript 类型定义
"@vitejs/plugin-vue": "^5.2.3", //Vite 的 Vue 插件
"@vitejs/plugin-vue-jsx": "^3.1.0", //Vite 的 Vue JSX 插件
"@vitest/coverage-v8": "^1.6.1", //Vitest 的覆盖率插件
"@vue/test-utils": "^2.4.5", //Vue 测试工具
"@vue/tsconfig": "^0.5.1", //Vue 的 TypeScript 配置
"cross-env": "^7.0.3", //跨平台环境变量设置
"cssnano": "^7.0.1", //CSS 压缩工具
"jsdom": "^24.0.0", //JavaScript DOM 环境模拟
"npm-run-all": "^4.1.5", //并行运行脚本
"postcss-color-mix": "^1.1.0", //PostCSS 插件
"postcss-each": "^1.1.0",
"postcss-each-variables": "^0.3.0",
"postcss-for": "^2.1.1",
"postcss-nested": "^6.0.1",
"prettier": "^3.2.5", //代码格式化工具
"rollup": "^4.18.0", //打包工具
"shelljs": "^0.8.5", //Shell 命令封装
"typescript": "^5.4.5",
"vite": "^5.4.14",
"vite-plugin-dts": "^3.9.1", //生成 TypeScript 声明文件
"vitest": "^1.6.1",
"vue-tsc": "^1.8.27" //Vue 的 TypeScript 编译器
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2", //Font Awesome 图标库核心
"@fortawesome/free-solid-svg-icons": "^6.5.2", //Font Awesome 实体图标
"@fortawesome/vue-fontawesome": "^3.0.8", //Vue 3 的 Font Awesome 集成
"@popperjs/core": "^2.11.8", //用于弹窗定位的 Popper.js
"@tyche/components": "workspace:*", //工作区内的组件库
"@tyche/hooks": "workspace:*", //工作区内的钩子库
"@tyche/theme": "workspace:*", //工作区内的主题库
"@tyche/utils": "workspace:*", //工作区内的工具库
"@tyche/locale": "workspace:*", //工作区内的国际化库
"@tyche/constants": "workspace:*", //工作区内的常量库
"async-validator": "^4.2.5", //表单验证库
"lodash-es": "^4.17.21", //Lodash 的 ES 模块版本
"tyche": "workspace:*",
"vue": "^3.5.13" //Vue3核心库
}
}

开发依赖

  • vue3-i18n:Vue 国际化库
1
2
3
4
5
6
7
8
9
总结
核心依赖:
vue:所有 Vue 3 相关包的依赖。
@fortawesome/fontawesome-svg-core:图标库。
async-validator:表单验证。
开发依赖:
测试:vitest, @vue/test-utils。
构建:vite, rollup。
工具:typescript, prettier。
1
2
pnpm ls <package-name>  # 查看依赖树
pnpm why <package-name> # 查看依赖原因

├─ pnpm-lock.yaml

环境🔒

├─ pnpm-workspace.yaml

PNPM 工作区的配置文件,用于管理多个包之间的依赖关系

1
2
3
packages:
- "packages/*"
- "libs/*"

所有位于 packages 目录下的子目录都被视为独立的包,并且是工作区的一部分

同样的,libs 目录下的子目录也被视为独立的包,被视为工作区的一部分

如果你有一个包在 packages 文件夹中依赖了另一个位于 libs 文件夹中的包,PNPM 不会从远程仓库下载该依赖,而是创建一个符号链接指向该本地包的位置。

当你在一个工作区内的任意一个包中安装依赖时,PNPM 会将这些依赖安装在工作区根目录下的 node_modules 文件夹内。工作区内所有的包可以共享相同的依赖版本,减少了重复安装和存储空间的浪费

├─ postcss.config.cjs

PostCSS 是一个使用 JavaScript 转换 CSS 的工具

该文件是 PostCSS 的配置文件

详情见文档 PostCSS中文网Github/postcss

1
2
3
4
5
6
7
8
9
10
11
12
13
/* eslint-env node */
module.exports = {
// publicPath: process.env.NODE_ENV === 'production' ? '/Tyche/' : '/',
plugins: [//一系列 PostCSS 插件
require('postcss-nested'),//支持嵌套
require('postcss-each-variables'),//循环功能
require('postcss-each')({
plugins: {
beforeEach: [require('postcss-for'), require('postcss-color-mix')],//for:循环控制;color-mix:颜色混合
},
}),
],
}

├─ tsconfig.build.json

1

├─ tsconfig.json

TypeScript 编译器的配置文件,定义如何编译项目中的 TypeScript 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"extends": "@vue/tsconfig/tsconfig.dom.json",//使用 Vue 提供的基础 TypeScript 配置,并在其基础上进行扩展。
"compilerOptions": {//包含一系列编译选项
"target": "ES2020",//编译后的 JavaScript 目标版本为 ES2020
"useDefineForClassFields": true,
"module": "ESNext",//使用 "ESNext" 模块系统为了支持最新的 ECMAScript 模块标准
"lib": ["ES2020", "DOM", "DOM.Iterable"],//列出了编译过程中需要包含的库文件
"skipLibCheck": true,//忽略所有声明文件(.d.ts)的类型检查,加快编译速度

/* Bundler mode */
"moduleResolution": "bundler",//适应现代构建工具如 Vite 的模块解析机制
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",//JSX 将被保留以便后续处理
"jsxImportSource": "vue",

/* Linting */
"strict": true,//严格模式
"noUnusedLocals": true,//禁止未使用的局部变量
"noUnusedParameters": true,//禁止未使用的函数参数
"noFallthroughCasesInSwitch": true
},
"include": [//应被包含在编译过程中的文件
"env.d.ts",
"packages/**/*.ts",
"packages/**/*.tsx",
"packages/**/*.vue",
"packages/play/src/stories/*.stories.ts",
"vitest.config.ts"
],
"exclude": ["packages/components/vitest.config.ts","packages/components/index.test.ts"]//应被排除在编译过程中的文件
}

├─ tsconfig.node.json

专门为 Node.js 环境下的 TypeScript 配置文件

1
2
3
4
5
6
7
8
9
10
{
"extends": "@tsconfig/node18/tsconfig.json",//基于 Node.js 18 版本的标准配置
"include": ["packages/**/**.config.ts"],//所有以 .config.ts 结尾的文件都应该被包含进来
"compilerOptions": {
"composite": true,//该项目是一个复合项目,可以与其他项目共享编译输出
"module": "ESNext",//使用 "ESNext" 模块系统为了支持最新的 ECMAScript 模块标准
"moduleResolution": "Bundler",//适应现代构建工具如 Vite 的模块解析机制
"types": ["node"]//指定要包含的类型声明,确保 Node.js 的全局变量和模块都能被正确识别
}
}

├─ vitest.config.ts

Vitest 测试框架的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <reference types="vitest" /> //引用类型声明:告诉TypeScript编译器包含Vitest的类型定义
import { defineConfig } from "vite"; //定义配置对象函数
import { resolve } from 'path'
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx()],//被应用到 Vite 构建过程中的插件[对 Vue 单文件组件的支持 & JSX 语法的支持]
define: { //定义全局常量(PROD, DEV, TEST)
PROD: JSON.stringify(false),
DEV: JSON.stringify(false),
TEST: JSON.stringify(true),
},
test: {
globals: true, //将测试框架暴露为全局变量,可以直接使用 describe, it, expect 等而无需每次都导入
environment: "jsdom", //测试将在模拟的浏览器环境中运行,对标需要 DOM 操作的测试
setupFiles:[resolve(__dirname, './vitest.setup.ts')] //指定vitest.setup.ts在所有测试之前自动运行,用于全局的初始化工作
},
});

└─ vitest.setup.ts

通常用于在运行测试之前执行一些初始化逻辑

在这个项目中,初始化 FontAwesome 图标库,整个测试过程中使用 Solid 风格的图标

1
2
3
4
5
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";//所有的 Solid 风格图标集

library.add(fas);//将 Solid 图标添加到了 FontAwesome 的库中
//任何需要使用 Solid 图标的组件或测试都可以直接访问这些图标,而不需要再次单独引入它们

避免了由于图标未加载而导致的测试失败

扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { far } from '@fortawesome/free-regular-svg-icons'
import { fab } from '@fortawesome/free-brands-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
library.add(fas,far,fab)

app.component('font-awesome-icon', FontAwesomeIcon)

//大小
<font-awesome-icon icon="fa-solid fa-house" size="xs"/>
<font-awesome-icon icon="fa-solid fa-house" size="lg"/>
<font-awesome-icon icon="fa-solid fa-house" size="2x"/>
<font-awesome-icon icon="fa-solid fa-house" size="4x"/>

//旋转
<font-awesome-icon icon="fa-solid fa-house" rotation="0"/>
<font-awesome-icon icon="fa-solid fa-house" rotation="90"/>
<font-awesome-icon icon="fa-solid fa-house" rotation="180"/>
<font-awesome-icon icon="fa-solid fa-house" rotation="270"/>

各工作区依赖环境详情

根目录

  • 生产环境依赖
    • @tyche/components: workspace:*
    • @tyche/hooks: workspace:*
    • @tyche/theme: workspace:*
    • @tyche/utils: workspace:*
    • @tyche/locale: workspace:*
    • @tyche/constants: workspace:*
    • @tyche/vite-plugins: workspace:*
    • @fortawesome/fontawesome-svg-core: ^6.5.2
    • @fortawesome/free-solid-svg-icons: ^6.5.2
    • @fortawesome/vue-fontawesome: ^3.0.8
    • @popperjs/core: ^2.11.8
    • @vitepress-preview/component: workspace:*
    • async-validator: ^4.2.5
    • eric-ui: workspace:*
    • lodash-es: ^4.17.21
    • vue: ^3.4.27
  • 开发环境依赖
    • @types/lodash-es: ^4.17.12
    • @types/node: ^20.12.12
    • @types/shelljs: ^0.8.15
    • @vitejs/plugin-vue: ^5.0.4
    • @vitejs/plugin-vue-jsx: ^3.1.0
    • @vitest/coverage-v8: ^1.4.0
    • @vue/test-utils: ^2.4.5
    • @vue/tsconfig: ^0.5.1
    • cross-env: ^7.0.3
    • cssnano: ^7.0.1
    • jsdom: ^24.0.0
    • npm-run-all: ^4.1.5
    • postcss-color-mix: ^1.1.0
    • postcss-each: ^1.1.0
    • postcss-each-variables: ^0.3.0
    • postcss-for: ^2.1.1
    • postcss-nested: ^6.0.1
    • prettier: ^3.2.5
    • rollup: ^4.18.0
    • shelljs: ^0.8.5
    • typescript: ^5.4.5
    • vite: ^5.2.11
    • vite-plugin-dts: ^3.9.1
    • vitest: ^1.6.0
    • vue-tsc: ^1.8.27
    • vue3-i18n: ^1.1.5

/packages/utils

  • 生产环境依赖: 无明确声明
  • 开发环境依赖
    • 无(仅包含一个测试脚本)

/packages/components

  • 生产环境依赖
    • axios: ^1.6.8 (在 devDependencies 中, 可能是误置)
  • 开发环境依赖
    • 无明确声明(除了 axios 外)

/packages/constants

  • 生产环境依赖: 无
  • 开发环境依赖: 无

/packages/core

  • 生产环境依赖
    • @fortawesome/fontawesome-svg-core: ^6.5.1
    • @fortawesome/free-solid-svg-icons: ^6.5.1
    • @fortawesome/vue-fontawesome: ^3.0.6
    • @popperjs/core: ^2.11.8
    • async-validator: ^4.2.5
  • 开发环境依赖
    • @rollup/plugin-terser: ^0.4.4
    • rollup-plugin-visualizer: ^5.12.0
    • terser: ^5.31.0
    • vite-plugin-compression: ^0.5.1

/packages/docs

  • 生产环境依赖: 无
  • 开发环境依赖
    • @vitepress-demo-preview/component: ^2.3.2
    • @vitepress-demo-preview/plugin: ^1.2.3
    • vitepress: 1.1.4

/packages/hooks

  • 生产环境依赖: 无
  • 开发环境依赖
    • 无(但有 peerDependencies)
    • peerDependencies:
      • vue: ^3.4.27
      • lodash-es: ^4.17.21

/packages/locale

  • 生产环境依赖: 无
  • 开发环境依赖: 无

/packages/play

  • 生产环境依赖: 无
  • 开发环境依赖: 无

/packages/theme

  • 生产环境依赖: 无
  • 开发环境依赖
    • @chromatic-com/storybook: 1.3.3
    • @storybook/addon-essentials: ^8.0.10
    • @storybook/addon-interactions: ^8.0.10
    • @storybook/addon-links: ^8.0.10
    • @storybook/blocks: ^8.0.10
    • @storybook/test: ^8.0.10
    • @storybook/vue3: ^8.0.10
    • @storybook/vue3-vite: ^8.0.10
    • @vitejs/plugin-vue: ^5.0.4
    • chromatic: ^11.3.1
    • storybook: ^8.0.10
1
请注意,对于 `/packages/components`,其中 `axios` 被放置在 `devDependencies` 中,这可能是错误配置,除非它确实只用于开发环境。此外,某些包如 `/packages/hooks` 使用了 `peerDependencies` 来定义其对其他包的依赖关系,这些依赖需要由使用者提供。