听阳光的猫

道阻且长、望你如一

0%

vue造轮子

框架搭建

项目初始化

请使用git、Github和npm初始化一个仓库,要求:

  1. 在Github上有一个远程仓库
  2. 仓库在有一个package.json
  3. 仓库在有一个LICENSE文件
  4. 仓库在有一个README.md文件
  5. 仓库在有一个index.html文件
1
2
3
4
5
6
7
8
9
10
11
git init

git add README.md

git commit -m "first commit"

git branch -M main

git remote add origin git@github.com:Derek-Dong/wheels.git

git push -u origin main

许可证明

创建LICENSE,选择MIT

使用npm

npm init

git add .

git commit -m 'npm init'

git pull

git push

安装Vue

npm install vue

添加.gitignore

node_modules/

创建index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.w-button {
font-size: var(--font-size);
height: var(--button-height);
padding: 0 1em;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--button-bg);
}
.w-button:hover {
border-color: var(--border-color-hover);
}
.w-button:active {
background-color: var(--button-active-bg);
}
.w-button:focus {
outline: none;
}

</style>
</head>
<body>
<div id="app">
<w-button></w-button>
</div>
<script src="./node_modules/vue/dist/vue.min.js"></script>
<script src="./button.js"></script>
<script>
new Vue({
el: '#app',
})
</script>
</body>
</html>

创建button.js

1
2
3
4
5
Vue.component('w-button', {
template: `
<button class="w-button">按钮</button>
`
})

请使用parcel来启动你的应用,要求:

  1. 安装parcel-bundler
  2. 创建src/app.js文件
  3. 创建src/button.vue文件
  4. index.html中引用src/app.js文件
  5. src/app.js中引用vuesrc/button.vue
  6. <w-button></w-button>变成<button>按钮</button>
  7. 访问http://localhost:1234可以正常查看按钮

安装parcel

npm i -D parcel-bundler

app.js

1
2
3
4
5
6
7
8
import Vue from 'vue'
import Button from './button'

Vue.component('w-button', Button)

new Vue({
el: '#app',
})

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
<template>
<button class="w-button">按钮</button>
</template>

<script>
export default {

}
</script>

<style lang="scss">
.w-button {
font-size: var(--font-size);
height: var(--button-height);
padding: 0 1em;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--button-bg);
&:hover {
border-color: var(--border-color-hover);
}
&:active {
background-color: var(--button-active-bg);
}
&:focus {
outline: none;
}
}
</style>

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
</head>
<body>
<div id="app">
<w-button></w-button>
</div>
<script src="./src/app.js"></script>
</body>
</html>

./node_modules/.bin/parcel

报错

1
2
3
4
5
6
7
8
./node_modules/.bin/parcel : 无法加载文件 E:\font-end\Project\wheels\node_module 
s\.bin\parcel.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.mi
crosoft.com/fwlink/?LinkID=135170 中的 about_Execution_Policies。
所在位置 行:1 字符: 1
+ ./node_modules/.bin/parcel
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : SecurityError: (:) [],PSSecurityException
+ FullyQualifiedErrorId : UnauthorizedAccess

解决方法:

打开PowerShell

输入set-executionpolicy remotesigned,之后选择Y,问题就解决了。

No entries found.

解决方法:

将之前的命令改成./node_modules/.bin/parcel index.html

简写npx parcel index.html

1
2
3
vue.runtime.esm.js:734 [Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

(found in <Root>)

解决方法:在package.json里面添加

1
2
3
"alias": {
"vue" : "./node_modules/vue/dist/vue.common.js"
}

./node_modules/.bin/parcel --no-cache

之后运行

git add .

git commit -m "加入parcel,将button组件改为单文件组件"

git push

git add .

git commit -m "ignore .cache & dist"

git push

为了避免每次报错可以选择删除cache

rm -rf .cache

添加icon

创建src/icon.vue,要求

可以把<w-icon name="settings"></w-icon>转换成

1
2
3
<svg class="icon">
<use xlink:href="#i-settings"></use>
</svg>

添加icon和iconPosition属性

image-20201208163132532

image-20201208163638983

image-20201208163508671

验证iconPosition为left或right中的一个

image-20201208165111988

将svg代码整理到icon.vue

image-20201208171353501

image-20201208172922392

icon.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<svg class="w-icon">
<use :xlink:href="`#i-${name}`"></use>
</svg>
</template>

<script>
export default {
props:['name']
};
</script>

<style lang="scss">
.w-icon {
width: 1em;
height: 1em;
}
</style>

添加loading

完善src/button.vue

按钮被点击时会触发click事件,即<w-button @click="xxx"></w-button>

添加loading状态

image-20201208184840490

image-20201208185054160

添加click事件

image-20201208184256126

image-20201208184613660

image-20201208185529705

创建button group

image-20201208190605592

image-20201208190838502

button-group.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
<template>
<div class="g-button-group">
<slot></slot>
</div>
</template>

<script>
export default {

}
</script>

<style lang="scss">
.g-button-group {
display: inline-flex;
vertical-align: middle;
> .g-button {
border-radius: 0;
margin-left: -1px;
&:first-child {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
&:last-child {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
&:hover {
position: relative;
z-index: 1;
}
}
}
</style>

检测子元素是不是button

image-20201208192715302

第一个元素不需要margin-left: -1px;

image-20201208192954867

单元测试与mock

BBD Behavior Driven Development 行为驱动开发

TDD Test Driven Development 测试驱动开发

Assert 断言

使用chai.expect添加四个测试用例

npm i -D chai

app.js

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
import Vue from 'vue'
import Button from './button'
import Icon from './icon'
import ButtonGroup from './button-group'

Vue.component('w-button', Button)
Vue.component('w-icon', Icon)
Vue.component('w-button-group', ButtonGroup)

new Vue({
el: '#app',
data: {
loading1: false,
loading2: true,
loading3: false,
}
})

import chai from 'chai'

const expect = chai.expect
// 单元测试
{
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings'
}
})
vm.$mount()
let useElement = vm.$el.querySelector('use')
let href = useElement.getAttribute('xlink:href')
expect(href).to.eq('#i-settings')
vm.$el.remove()
vm.$destroy()
}

{
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
loading: true
}
})
vm.$mount()
let useElement = vm.$el.querySelector('use')
let href = useElement.getAttribute('xlink:href')
expect(href).to.eq('#i-loading')
vm.$el.remove()
vm.$destroy()
}

{
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
}
})
vm.$mount(div)
let svg = vm.$el.querySelector('svg')
let {order} = window.getComputedStyle(svg)
expect(order).to.eq('1')
vm.$el.remove()
vm.$destroy()
}

{
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
iconPosition: 'right'
}
})
vm.$mount(div)
let svg = vm.$el.querySelector('svg')
let {order} = window.getComputedStyle(svg)
expect(order).to.eq('2')
vm.$el.remove()
vm.$destroy()
}

{
// mock
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
}
})
vm.$mount()
vm.$on('click', function(){
expect(1).to.eq(1)
})
// 希望这个函数被执行
let button = vm.$el
button.click()
}

使用chai.spy监听回调函数

npm i -D chai-spies

image-20201209131900351

使用karma做自动化测试

安装各种工具

npm i -D karma karma-chrome-launcher karma-mocha karma-sinon-chai mocha sinon sinon-chai karma-chai karma-chai-spies

创建karma配置

新建karma.conf.js

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
module.exports = function(config) {
config.set({
// 基本路径,用于files和exclude上
basePath: '',
//可用的框架和库
frameworks: ['mocha', 'sinon-chai'],
client: {
chai: {
includeStack: true
}
},

//需要加载到浏览器测试的文件
files: ['dist/**/*.test.js', 'dist/**/*.test.css'],

//排除文件
exclude: [],

// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {},

// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress'],

// web server port
port: 9876,

// enable / disable colors in the output (reporters and logs)
colors: true,

// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,

//启用或禁用自动检测文件变化进行测试
autoWatch: true,

//指定浏览器
browsers: ['ChromeHeadless'],

// 开启或禁用持续集成模式
// 设置为true, Karma将打开浏览器,执行测试并最后退出
singleRun: false,

//并打级别(打开浏览器数量)
concurrency: Infinity
});
};

创建test/button.test.js文件

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
const expect = chai.expect;
import Vue from 'vue'
import Button from '../src/button'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Button', () => {
it('存在.', () => {
expect(Button).to.be.ok
})
it('可以设置icon.', () => {
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings'
}
}).$mount()
const useElement = vm.$el.querySelector('use')
expect(useElement.getAttribute('xlink:href')).to.equal('#i-settings')
vm.$destroy()
})
it('可以设置loading.', () => {
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
loading: true
}
}).$mount()
const useElements = vm.$el.querySelectorAll('use')
expect(useElements.length).to.equal(1)
expect(useElements[0].getAttribute('xlink:href')).to.equal('#i-loading')
vm.$destroy()
})
it('icon 默认的 order 是 1', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
}
}).$mount(div)
const icon = vm.$el.querySelector('svg')
expect(getComputedStyle(icon).order).to.eq('1')
vm.$el.remove()
vm.$destroy()
})
it('设置 iconPosition 可以改变 order', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
iconPosition: 'right'
}
}).$mount(div)
const icon = vm.$el.querySelector('svg')
expect(getComputedStyle(icon).order).to.eq('2')
vm.$el.remove()
vm.$destroy()
})
it('点击 button 触发 click 事件', () => {
const Constructor = Vue.extend(Button)
const vm = new Constructor({
propsData: {
icon: 'settings',
}
}).$mount()

const callback = sinon.fake();
vm.$on('click', callback)
vm.$el.click()
expect(callback).to.have.been.called

})
})

创建测试脚本

package.json里面找到scripts并改写

1
2
3
4
"scripts": {
"dev-test": "parcel watch test/* --no-cache & karma start",
"test": "parcel build test/* --no-minify && karma start --single-run",
},

运行测试脚本

npm run test

报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Chrome Headless 87.0.4280.66 (Windows 10) Button 可以设置icon. FAILED
TypeError: Cannot read property 'getAttribute' of null
at Context.<anonymous> (dist/button.test.js:216:23)
Chrome Headless 87.0.4280.66 (Windows 10) Button 可以设置loading. FAILED
AssertionError: expected 0 to equal 1
at Proxy.assertEqual (node_modules/chai/chai.js:1387:12)
at Proxy.methodWrapper (node_modules/chai/chai.js:7824:25)
at Context.<anonymous> (dist/button.test.js:229:35)
Chrome Headless 87.0.4280.66 (Windows 10) Button icon 默认的 order 是 1 FAILED
TypeError: Failed to execute 'getComputedStyle' on 'Window': parameter 1 is not of type 'Element'.
at Context.<anonymous> (dist/button.test.js:245:12)
Chrome Headless 87.0.4280.66 (Windows 10) Button 设置 iconPosition 可以改变 order FAILED
TypeError: Failed to execute 'getComputedStyle' on 'Window': parameter 1 is not of type 'Element'.
at Context.<anonymous> (dist/button.test.js:262:12)
Chrome Headless 87.0.4280.66 (Windows 10): Executed 6 of 6 (4 FAILED) (0.247 secs / 0.032 secs)
TOTAL: 4 FAILED, 2 SUCCESS

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
<script>
import Icon from './icon'
export default {
components: {
'w-icon': Icon
},
// props:['icon','iconPosition']
props: {
icon: {},
loading: {
type: Boolean,
default: false
},
iconPosition: {
type: String,
default:'left',
validator(value) {
return value === 'left' || value === 'right'
}
}
}
}
</script>

优化

package.json里面找到scripts并改写

1
2
3
4
"scripts": {
"dev-test": "parcel watch test/* --no-cache & karma start",
"test": "parcel build test/* --no-cache --no-minify && karma start --single-run",
},

运行代码可以使用npm run dev-test

使用travisci做持续集成

登录https://www.travis-ci.org/并注册账户,和github仓库中项目绑定

在根目录下新建.travis.yml

1
2
3
4
5
6
7
8
9
language: node_js
node_js:
- "8"
addons:
chrome: stable
sudo: required
before_script:
- "sudo chown root /opt/google/chrome/chrome-sandbox"
- "sudo chmod 4755 /opt/google/chrome/chrome-sandbox"

之后git push到仓库

如果没有反应可以自己提交

More options/Trigger build

使用npm发布自己的包

确保代码测试通过

npm run test 全是绿色

上传代码到npmjs.org

创建index.js

1
2
3
4
5
6
import Button from './src/button'
import ButtonGroup from './src/button-group'
import Icon from './src/icon'

export {ButtonGroup, Button,Icon}

https://www.npmjs.com/注册一个账户

在wheels项目根目录运行npm adduser

运行npm publish

若是上传失败可能是npm存在同名的包,修改包名再次上传即可。

使用包的方式

使用vue-cli

使用webpack

使用parcel

分别使用这些方式使用自己的包

转义好了之后再使用

npx parcel build index.js --no-cache --no-minify

package.jsonmain改为dist/index.js

使用npm link或者yarn link加速调试

更新package.json里面的version 然后npm publish 别人通过npm update xxx 来更新

书写README

更新README.md

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# wheels - 一个Vue UI组件

[![Build Status](https://www.travis-ci.org/Derek-Dong/wheels.svg?branch=main)](https://www.travis-ci.org/Derek-Dong/wheels)

## 介绍

## 开始使用

1. 安装

使用本框架前,请在CSS中开启border-box
```
*{box-sizing: border-box;}
​```

## 文档

## 提问

## 变更记录

## 联系方式

## 贡献代码

完善开始使用

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
# wheels - 一个Vue UI组件

[![Build Status](https://www.travis-ci.org/Derek-Dong/wheels.svg?branch=main)](https://www.travis-ci.org/Derek-Dong/wheels)

## 介绍

这是我在学习 Vue 过程中做的一个UI框架,希望对你有用。

## 开始使用

1. 添加 CSS 样式

使用本框架前,请在CSS中开启border-box
```
*,*::before,*::after{box-sizing: border-box;}
```
IE 8 及以上浏览器都支持此样式。

你还需要设置默认颜色等变量(后续会改为 SCSS 变量)
```
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
```
IE 15 及以上浏览器都支持此样式。

2. 安装wheels
```
npm i --save d-wheels
```

3. 引入 wheels
```
import {Button, ButtonGroup, Icon} from 'd-wheels'
import 'd-wheels/dist/index.css'

export default {
name: 'app',
components: {
'w-button': Button,
'w-icon': Icon
}
}
```

4. 引入 svg symbols
```
<script src="//at.alicdn.com/t/font_2255771_2568c4dk59e.js"></script>
```

## 文档

## 提问

## 变更记录

## 联系方式

## 贡献代码

去除对iconfont的依赖

新建src/svg.js,将//at.alicdn.com/t/font_2255771_2568c4dk59e.js 链接里面的内容粘贴。

image-20201210124109704

简化parcel命令

package.json里面找到scripts并改写

1
2
3
4
5
"scripts": {
"start": "parcel index.html --no-cache",
"dev-test": "parcel watch test/* --no-cache & karma start",
"test": "parcel build test/* --no-minify && karma start --single-run"
},

文本输入框

写input的样式

新建src/input.vue 并在app.js中导入

image-20201210141447676

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.box {
margin: 20px;
}
</style>
</head>
<body>
<div id="app">
<div class="box">
<w-input value="张三" disabled></w-input>
<w-input value="李四" readonly></w-input>
<w-input value="王五"></w-input>
</div>
<div class="box">
<w-input value="王" error="姓名不能少于两个字"></w-input>
</div>
<div class="box">
<w-button :loading="loading1" @click="loading1 = !loading1">按钮</w-button>
<w-button icon="settings" :loading="loading2" @click="loading2 = !loading2">按钮</w-button>
<w-button icon="settings" icon-position="right" :loading="loading3" @click="loading3 = !loading3">按钮</w-button>
<w-button-group>
<w-button icon="left">上一页</w-button>
<w-button>更多</w-button>
<w-button icon="right" icon-position="right">下一页</w-button>
</w-button-group>
</div>
</div>
<script src="./src/app.js"></script>
</body>
</html>

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
<template>
<div class="wrapper" :class="{error}">
<input :value="value" type="text" :disabled="disabled" :readonly="readonly">
<template v-if="error">
<icon name="error" class="icon-error"></icon>
<span class="errorMessage">{{error}}</span>
</template>
</div>
</template>

<script>
import Icon from './icon'

export default {
components: {Icon},
name: 'WheelsInput',
props: {
value: {
type: String
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
error: {
type: String
},
}
}
</script>

<style lang="scss" scoped>
$height: 32px;
$border-color: #999;
$border-color-hover: #666;
$border-radius: 4px;
$font-size: 12px;
$box-shadow-color: rgba(0, 0, 0, 0.5);
$red: #F1453D;
.wrapper {
font-size: $font-size;
display: inline-flex;
align-items: center;
> :not(:last-child) {
margin-right: .5em;
}
> input {
height: $height;
border: 1px solid $border-color;
border-radius: $border-radius;
padding: 0 8px;
font-size: inherit;
&:hover {
border-color: $border-color-hover;
}
&:focus {
box-shadow: inset 0 1px 3px $box-shadow-color;
outline: none;
}
&[disabled], &[readonly] {
border-color: #bbb;
color: #bbb;
cursor: not-allowed;
}
}
&.error {
> input { border-color: $red;}
}
.icon-error {
fill: $red;
}
.errorMeaasge {
color:$red;
}
}

</style>

svg.js

添加error和info图标

重新导入对应js

添加input的测试用例

src/app.js

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 Vue from 'vue'
import Button from './button'
import Icon from './icon'
import ButtonGroup from './button-group'
import Input from './input'

Vue.component('w-button', Button)
Vue.component('w-icon', Icon)
Vue.component('w-button-group', ButtonGroup)
Vue.component('w-input', Input)

new Vue({
el: '#app',
data: {
loading1: false,
loading2: true,
loading3: false,
},
created(){
setTimeout(()=>{
let event = new Event('change');
let inputElement = this.$el.querySelector('input')
inputElement.dispatchEvent(event)
console.log('hi')
},3000)
},
methods: {
inputChange (e) {
console.log(e)
}
}
})

index.html

image-20201210174401047

src/input.vue

image-20201210174809305

test/input.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Input from '../src/input'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Input', () => {

it('存在.', () => {
expect(Input).to.exist
})


describe('props', () => {
const Constructor = Vue.extend(Input)
let vm
afterEach(() => {
vm.$destroy()
})
it('接收 value', () => {
vm = new Constructor({
propsData: {
value: '1234'
}
}).$mount()
const inputElement = vm.$el.querySelector('input')
expect(inputElement.value).to.equal('1234')
})

it('接收 disabled', () => {
vm = new Constructor({
propsData: {
disabled: true
}
}).$mount()
const inputElement = vm.$el.querySelector('input')
expect(inputElement.disabled).to.equal(true)
})
it('接收 readonly', () => {
vm = new Constructor({
propsData: {
readonly: true
}
}).$mount()
const inputElement = vm.$el.querySelector('input')
expect(inputElement.readOnly).to.equal(true)
})

it('接收 error', () => {
vm = new Constructor({
propsData: {
error: '你错了'
}
}).$mount()
const useElement = vm.$el.querySelector('use')
expect(useElement.getAttribute('xlink:href')).to.equal('#i-error')
const errorMessage = vm.$el.querySelector('.errorMessage')
expect(errorMessage.innerText).to.equal('你错了')
})
})
describe('事件', () => {
const Constructor = Vue.extend(Input)
let vm
afterEach(() => {
vm.$destroy()
})
it('支持 change/input/focus/blur 事件', () => {
['change', 'input', 'focus', 'blur']
.forEach((eventName) => {
vm = new Constructor({}).$mount()
const callback = sinon.fake();
vm.$on(eventName, callback)
//触发input的change 事件
let event = new Event(eventName);
Object.defineProperty(
event, 'target', {
value: {value: 'hi'}, enumerable: true
}
)
let inputElement = vm.$el.querySelector('input')
inputElement.dispatchEvent(event)
expect(callback).to.have.been.calledWith('hi')
})
})
})
})

让input支持v-model

index.html

image-20201210183139377

app.js

image-20201210183214198

网格系统

add row and col

添加新文件

新建src/row.vue 和src/col.vue

git branch button-and-input

git add .

git commit -m "add row and col"

git push

git push origin button-and-input:button-and-input

row 和 col 的雏形完成

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.box {
margin: 20px;
}
</style>
</head>
<body>
<div id="app">
<w-row>
<w-col>1</w-col>
<w-col>2</w-col>
</w-row>
<w-row>
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
</w-row>
<w-row>
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
<w-col>4</w-col>
</w-row>
<w-row>
<w-col span="2">1</w-col>
<w-col span="22">11</w-col>
</w-row>
<w-row>
<w-col span="4">1</w-col>
<w-col span="20">11</w-col>
</w-row>
<hr>
<div class="box">
<w-input value="张三" disabled></w-input>
<w-input value="李四" readonly></w-input>
<w-input value="王五"></w-input>
</div>
<div class="box">
<w-input v-model="message"></w-input>
<p>{{message}}</p>
<button @click="message+=1">+1</button>
</div>
<div class="box">
<w-input value="王" error="姓名不能少于两个字"></w-input>
</div>
<div class="box">
<w-button :loading="loading1" @click="loading1 = !loading1">按钮</w-button>
<w-button icon="settings" :loading="loading2" @click="loading2 = !loading2">按钮</w-button>
<w-button icon="settings" icon-position="right" :loading="loading3" @click="loading3 = !loading3">按钮</w-button>
<w-button-group>
<w-button icon="left">上一页</w-button>
<w-button>更多</w-button>
<w-button icon="right" icon-position="right">下一页</w-button>
</w-button-group>
</div>
</div>
<script src="./src/app.js"></script>
</body>
</html>

app.js

image-20201214103638757

src/row.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="row">
<slot></slot>
</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
.row{
display: flex;
}
</style>

src/col.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
<template>
<div class="col" :class="[`col-${span}`]">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
}
}
}
</script>

<style lang="scss" scoped>
.col {
height: 100px;
background: grey;
width: 50%;
border: 1px solid red;
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
}
</style>

基本实现 row 和 col 的功能

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
:root {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.box {
margin: 20px;
}
</style>
</head>
<body>
<div id="app">
<div style="border: 1px solid black;">
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
</w-row>
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
</w-row>
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
<w-col>4</w-col>
</w-row>
<w-row gutter="20">
<w-col span="2">1</w-col>
<w-col span="20" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="20">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="18" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="8" offset="2">11</w-col>
<w-col span="8" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="4" offset="6">11</w-col>
<w-col span="8" offset="2">11</w-col>
</w-row>
</div>
<hr>
<div class="box">
<w-input value="张三" disabled></w-input>
<w-input value="李四" readonly></w-input>
<w-input value="王五"></w-input>
</div>
<div class="box">
<w-input v-model="message"></w-input>
<p>{{message}}</p>
<button @click="message+=1">+1</button>
</div>
<div class="box">
<w-input value="王" error="姓名不能少于两个字"></w-input>
</div>
<div class="box">
<w-button :loading="loading1" @click="loading1 = !loading1">按钮</w-button>
<w-button icon="settings" :loading="loading2" @click="loading2 = !loading2">按钮</w-button>
<w-button icon="settings" icon-position="right" :loading="loading3" @click="loading3 = !loading3">按钮</w-button>
<w-button-group>
<w-button icon="left">上一页</w-button>
<w-button>更多</w-button>
<w-button icon="right" icon-position="right">下一页</w-button>
</w-button-group>
</div>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/row.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
<template>
<div class="row" :style="{marginLeft: -gutter/2+'px', marginRight: -gutter/2+'px'}">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'WheelsRow',
props: {
gutter: {
type: [Number, String]
}
},
created () {
console.log('row created')
},
mounted () {
console.log('row mounted')
console.log(this.$children)
this.$children.forEach((vm) => {
vm.gutter = this.gutter
})
}
}
var div = document.createElement('div') // created
var childDiv = document.createElement('div') // child created
div.appendChild(childDiv) // child mounted
document.body.appendChild(div) // mounted
</script>

<style lang="scss" scoped>
.row{
display: flex;
}
</style>

src/col.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
<template>
<div class="col" :class="[span && `col-${span}`, offset && `offset-${offset}`]"
:style="{paddingLeft: gutter/2+'px', paddingRight: gutter/2+'px'}">
<div style="border: 1px solid green; height: 100px;">
<slot></slot>
</div>
</div>
</template>

<script>
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
},
data() {
return {
gutter: 0
}
},
created() {
console.log('col created');
},
mounted() {
console.log('col mounted');
}
}
</script>

<style lang="scss" scoped>
.col {
// height: 100px;
// background: grey;
width: 50%;
// border: 1px solid red;
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
</style>

重构 row 和 col

src/row.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
<template>
<div class="row" :style="rowStyle">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'WheelsRow',
props: {
gutter: {
type: [Number, String]
}
},
computed: {
rowStyle() {
let {gutter} = this
return {marginLeft: -gutter / 2 + 'px', marginRight: -gutter / 2 + 'px'}
}
},
mounted () {
this.$children.forEach((vm) => {
vm.gutter = this.gutter
})
}
}
</script>

<style lang="scss" scoped>
.row{
display: flex;
}
</style>

src/col.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
<template>
<div class="col" :class="colClass" :style="colStyle">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
},
data() {
return {
gutter: 0
}
},
computed: {
colClass() {
let {span, offset} = this
return [
span && `col-${span}`,
offset && `offset-${offset}`
]
},
colStyle() {
return {
paddingLeft: gutter / 2 + 'px',
paddingRight: gutter / 2 + 'px'
}
}
}
}
</script>

<style lang="scss" scoped>
.col {
// height: 100px;
// background: grey;
width: 50%;
// border: 1px solid red;
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
</style>

添加 row 的 align=left/right/center 属性

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
img {
max-width: 100%;
}
html {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.box {
margin: 20px;
}
</style>
<style>
.demoBox {
min-height: 50px;
background: grey;
border: 1px solid red;
}
.logo-wrapper {
padding: 10px;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<div id="app">
<w-row class="topbar">
<w-col class="demoBox" span="9">
<w-row align="left">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
<w-col>4</w-col>
</w-row>
</w-col>
<w-col class="demoBox" span="15">
<w-row align="left">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
<w-col>4</w-col>
<w-col>5</w-col>
<w-col>6</w-col>
<w-col>7</w-col>
</w-row>
</w-col>
</w-row>
<w-row class="logo-and-search-and-qrcode">
<w-col class="demoBox" span="4">
<div class="logo-wrapper">
<img src="" alt="">
</div>
</w-col>
<w-col class="demoBox" span="14"></w-col>
<w-col class="demoBox" span="6"></w-col>
</w-row>
<div style="border: 1px solid black;">
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
</w-row>
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
</w-row>
<w-row gutter="20">
<w-col>1</w-col>
<w-col>2</w-col>
<w-col>3</w-col>
<w-col>4</w-col>
</w-row>
<w-row gutter="20">
<w-col span="2">1</w-col>
<w-col span="20" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="20">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="18" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="8" offset="2">11</w-col>
<w-col span="8" offset="2">11</w-col>
</w-row>
<w-row gutter="20">
<w-col span="4">1</w-col>
<w-col span="4" offset="6">11</w-col>
<w-col span="8" offset="2">11</w-col>
</w-row>
</div>
<hr>
<div class="box">
<w-input v-model="message"></w-input>
<p>{{message}}</p>
<button @click="message+=1">+1</button>
</div>
<div class="box">
<w-button :loading="loading1" @click="loading1 = !loading1">按钮</w-button>
<w-button icon="settings" :loading="loading2" @click="loading2 = !loading2">按钮</w-button>
<w-button icon="settings" icon-position="right" :loading="loading3" @click="loading3 = !loading3">按钮</w-button>
<w-button-group>
<w-button icon="left">上一页</w-button>
<w-button>更多</w-button>
<w-button icon="right" icon-position="right">下一页</w-button>
</w-button-group>
</div>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/row.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
<template>
<div class="row" :style="rowStyle" :class="rowClass">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'WheelsRow',
props: {
gutter: {
type: [Number, String]
},
align: {
type: String,
validator(value) {
return ['left', 'right', 'center'].includes(value)
}
}
},
computed: {
rowStyle() {
let {gutter} = this
return {marginLeft: -gutter / 2 + 'px', marginRight: -gutter / 2 + 'px'}
},
rowClass() {
let {align} = this
return [align && `align-${align}`]
}
},
mounted () {
this.$children.forEach((vm) => {
vm.gutter = this.gutter
})
}
}
</script>

<style lang="scss" scoped>
.row{
display: flex;
&.align-left {
justify-content: flex-start;
}
&.align-right {
justify-content: flex-end;
}
&.align-center {
justify-content: center;
}
}
</style>

初步实现响应式

index.html

image-20201216111037710

src/row.vue

image-20201216104635844

src/col.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
<template>
<div class="col" :class="colClass" :style="colStyle">
<slot></slot>
</div>
</template>

<script>
let validator = (value) => {
let keys = Object.keys(value)
let valid = true
keys.forEach(key => {
if (!['span', 'offset'].includes(key)) {
valid = false
}
})
return valid
}
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
phone: {type: Object, validator,},
ipad: {type: Object, validator,},
narrowPc: {type: Object, validator,},
pc: {type: Object, validator,},
widePc: {type: Object, validator,}
},
data() {
return {
gutter: 0
}
},
computed: {
colClass () {
let {span, offset, phone, ipad, narrowPc, pc, widePc} = this
let phoneClass = []
return [
span && `col-${span}`,
offset && `offset-${offset}`,
... (phone ? [`col-phone-${phone.span}`] : []),
... (ipad ? [`col-ipad-${ipad.span}`] : []),
... (narrowPc ? [`col-narrow-pc-${narrowPc.span}`] : []),
... (pc ? [`col-pc-${pc.span}`] : []),
... (widePc ? [`col-wide-pc-${widePc.span}`] : []),
]
},
colStyle() {
return {
paddingLeft: this.gutter / 2 + 'px',
paddingRight: this.gutter / 2 + 'px'
}
}
}
}
</script>

<style lang="scss" scoped>
.col {
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
@media (max-width: 576px) {
$class-prefix: col-phone-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-phone-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 577px) and (max-width: 768px) {
$class-prefix: col-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 769px) and (max-width: 992px) {
$class-prefix: col-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 993px) and (max-width: 1200px) {
$class-prefix: col-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 1201px) {
$class-prefix: col-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
}
</style>

默认就是phone

index.html

image-20201216114131959

src/col.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
<template>
<div class="col" :class="colClass" :style="colStyle">
<slot></slot>
</div>
</template>

<script>
let validator = (value) => {
let keys = Object.keys(value)
let valid = true
keys.forEach(key => {
if (!['span', 'offset'].includes(key)) {
valid = false
}
})
return valid
}
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
ipad: {type: Object, validator,},
narrowPc: {type: Object, validator,},
pc: {type: Object, validator,},
widePc: {type: Object, validator,}
},
data() {
return {
gutter: 0
}
},
computed: {
colClass () {
let {span, offset, ipad, narrowPc, pc, widePc} = this
return [
span && `col-${span}`,
offset && `offset-${offset}`,
... (ipad ? [`col-ipad-${ipad.span}`] : []),
... (narrowPc ? [`col-narrow-pc-${narrowPc.span}`] : []),
... (pc ? [`col-pc-${pc.span}`] : []),
... (widePc ? [`col-wide-pc-${widePc.span}`] : []),
]
},
colStyle() {
return {
paddingLeft: this.gutter / 2 + 'px',
paddingRight: this.gutter / 2 + 'px'
}
}
}
}
</script>

<style lang="scss" scoped>
.col {
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
@media (min-width: 577px) and (max-width: 768px) {
$class-prefix: col-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 769px) and (max-width: 992px) {
$class-prefix: col-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 993px) and (max-width: 1200px) {
$class-prefix: col-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 1201px) {
$class-prefix: col-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
}
</style>

响应式基本完成

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
html {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
#app {
margin: 20px;
}
body {
font-size: var(--font-size);
}
</style>
<style>
.box {
margin: 20px;
}
</style>
<style>
.demo {
background-color: #ddd;
border: 1px solid #666;
height: 100px;
}
</style>
</head>
<body>
<div id="app">
<w-col span="24" :narrow-pc="{span:8}">
<div class="demo"></div>
</w-col>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/col.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
<template>
<div class="col" :class="colClass" :style="colStyle">
<slot></slot>
</div>
</template>

<script>
let validator = (value) => {
let keys = Object.keys(value)
let valid = true
keys.forEach(key => {
if (!['span', 'offset'].includes(key)) {
valid = false
}
})
return valid
}
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
ipad: {type: Object, validator,},
narrowPc: {type: Object, validator,},
pc: {type: Object, validator,},
widePc: {type: Object, validator,}
},
data() {
return {
gutter: 0
}
},
computed: {
colClass () {
let {span, offset, ipad, narrowPc, pc, widePc} = this
return [
span && `col-${span}`,
offset && `offset-${offset}`,
... (ipad ? [`col-ipad-${ipad.span}`] : []),
... (narrowPc ? [`col-narrow-pc-${narrowPc.span}`] : []),
... (pc ? [`col-pc-${pc.span}`] : []),
... (widePc ? [`col-wide-pc-${widePc.span}`] : []),
]
},
colStyle() {
return {
paddingLeft: this.gutter / 2 + 'px',
paddingRight: this.gutter / 2 + 'px'
}
}
}
}
</script>

<style lang="scss" scoped>
.col {
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
@media (min-width: 577px) {
$class-prefix: col-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 769px) {
$class-prefix: col-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 993px) {
$class-prefix: col-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 1201px) {
$class-prefix: col-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
}
</style>

稍微重构一下

index.html

image-20201216124943360

src/col.vue

image-20201216125048005

添加row的测试用例

test/row.test.js

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
<template>
<div class="col" :class="colClass" :style="colStyle">
<slot></slot>
</div>
</template>

<script>
let validator = (value) => {
let keys = Object.keys(value)
let valid = true
keys.forEach(key => {
if (!['span', 'offset'].includes(key)) {
valid = false
}
})
return valid
}
export default {
name: 'WheelsCol',
props: {
span: {
type: [Number, String]
},
offset: {
type: [Number, String]
},
ipad: {type: Object, validator,},
narrowPc: {type: Object, validator,},
pc: {type: Object, validator,},
widePc: {type: Object, validator,}
},
data() {
return {
gutter: 0
}
},
methods: {
createClasses(obj, str = '') {
if (!obj) {return []}
let array = []
if (obj.span) {
array.push(`col-${str}${obj.span}`)
}
if (obj.offset) {
array.push(`offset-${str}${obj.offset}`)
}
return array
}
},
computed: {
colClass () {
let {span, offset, ipad, narrowPc, pc, widePc} = this
let createClasses = this.createClasses
return [
...createClasses({span, offset}),
...createClasses(ipad, 'ipad-'),
...createClasses(narrowPc, 'narrow-pc-'),
...createClasses(pc, 'pc-'),
...createClasses(widePc, 'wide-pc-')
]
},
colStyle() {
return {
paddingLeft: this.gutter / 2 + 'px',
paddingRight: this.gutter / 2 + 'px'
}
}
}
}
</script>

<style lang="scss" scoped>
.col {
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
@media (min-width: 577px) {
$class-prefix: col-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-ipad-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 769px) {
$class-prefix: col-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-narrow-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 993px) {
$class-prefix: col-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
@media (min-width: 1201px) {
$class-prefix: col-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n / 24) * 100%;
}
}
$class-prefix: offset-wide-pc-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
margin-left: ($n / 24) * 100%;
}
}
}
}
</style>

添加col的测试用例

test/col.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Col from '../src/col'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Col', () => {

it('存在.', () => {
expect(Col).to.exist
})
it('接收 span 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
span: 1
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('col-1')).to.eq(true)
div.remove()
vm.$destroy()
})
it('接收 offset 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
offset: 1
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('offset-1')).to.eq(true)
div.remove()
vm.$destroy()
})
it('接收 pc 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
pc: {span: 1, offset: 2}
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('col-pc-1')).to.eq(true)
expect(vm.$el.classList.contains('offset-pc-2')).to.eq(true)
div.remove()
vm.$destroy()
})
it('接收 ipad 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
ipad: {span: 1, offset: 2}
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('col-ipad-1')).to.eq(true)
expect(vm.$el.classList.contains('offset-ipad-2')).to.eq(true)
div.remove()
vm.$destroy()
})
it('接收 narrow-pc 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
narrowPc: {span: 1, offset: 2}
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('col-narrow-pc-1')).to.eq(true)
expect(vm.$el.classList.contains('offset-narrow-pc-2')).to.eq(true)
div.remove()
vm.$destroy()
})
it('接收 wide-pc 属性', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Col)
const vm = new Constructor({
propsData: {
widePc: {span: 1, offset: 2}
}
}).$mount(div)
const element = vm.$el
expect(vm.$el.classList.contains('col-wide-pc-1')).to.eq(true)
expect(vm.$el.classList.contains('offset-wide-pc-2')).to.eq(true)
div.remove()
vm.$destroy()
})

})

默认布局

创建layout组件

src/content.vue

src/header.vue

src/layout.vue

src/sider.vue

基本功能完成

index.html

image-20201216152414758

src/app.js

image-20201216152446939

src/content.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="content">
<slot></slot>
</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
.content {
flex-grow: 1;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="footer">
<slot></slot>
</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>

</style>

src/header.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="header">
<slot></slot>
</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
.content {
flex-grow: 1;
}
</style>

src/layout.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="sider">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsSider'
}
</script>

<style lang="scss" scoped>

</style>

src/sider.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
<template>
<div class="layout" :class="layoutClass">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsLayout',
data () {
return {
layoutClass: {
hasSider: false
}
}
},
mounted () {
this.$children.forEach((vm) => {
if (vm.$options.name === 'WheelsSider') {
this.layoutClass.hasSider = true
}
})
}
}
</script>

<style lang="scss" scoped>
.layout {
flex-grow: 1;
display: flex;
flex-direction: column;
border: 1px solid red;
&.hasSider {
flex-direction: row;
}
}
</style>

添加简单动画

index.html

image-20201216155554213

src/layout.vue

删掉这行代码

1
border: 1px solid red;

src/sider.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
<template>
<transition name="slide">
<div class="sider" v-if="visible">
<slot></slot>
<button @click="visible=false">close</button>
</div>
</transition>
</template>
<script>
export default {
name: 'WheelsSider',
data () {
return {
visible: true
}
},
}
</script>

<style lang="scss" scoped>
.sider {
position: relative;
> button {
position: absolute;
top: 0;
right: 0;
}
}
.slide-enter-active, .slide-leave-active {
transition: all .3s;
}
.slide-enter, .slide-leave-to {
margin-left: -200px;
}
</style>

Toast组件

添加 toast 和 plugin

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
html {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
body {
font-size: var(--font-size);
}
</style>
</head>
<body>
<div id="app">
<button @click="showToast">点我</button>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/app.js

image-20201216192014027

src/plugin.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import Toast from './toast'

export default {
install (Vue, options) {
Vue.prototype.$toast = function (message) {
let Constructor = Vue.extend(Toast)
let toast = new Constructor()
toast.$slots.default = [message]
toast.$mount()
document.body.appendChild(toast.$el)
}
}
}

src/toast.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
<template>
<div class="toast">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsToast'
}
</script>
<style scoped lang="scss">
$font-size: 14px;
$toast-height: 40px;
$toast-bg: rgba(0, 0, 0, 0.75);
.toast {
font-size: $font-size; height: $toast-height; line-height: 1.8;
position: fixed; top: 0; left: 50%; transform: translateX(-50%); display: flex;
color: white;
align-items: center;
background: $toast-bg;
border-radius: 4px;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.50);
padding: 0 16px;
}
</style>

实现多行文字等功能

src/app.js

image-20201216220700351

src/plugin.js

image-20201216220743213

src/toast.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
<template>
<div class="toast" ref="wrapper">
<div class="message">
<slot v-if="!enableHtml"></slot>
<div v-else v-html="$slots.default[0]"></div>
</div>
<div class="line" ref="line"></div>
<span class="close" v-if="closeButton" @click="onClickClose">
{{closeButton.text}}
</span>
</div>
</template>
<script>
export default {
name: 'WheelsToast',
props: {
autoClose: {
type: Boolean,
default: true
},
autoCloseDelay: {
type: Number,
default: 50
},
closeButton: {
type: Object,
default () {
return {
text: '关闭', callback: undefined
}
}
},
enableHtml: {
type: Boolean,
default: false
}
},
created () {
},
mounted () {
this.updateStyles()
this.execAutoClose()
},
methods: {
updateStyles () {
this.$nextTick(() => {
this.$refs.line.style.height =
`${this.$refs.wrapper.getBoundingClientRect().height}px`
})
},
execAutoClose () {
if (this.autoClose) {
setTimeout(() => {
this.close()
}, this.autoCloseDelay * 1000)
}
},
close () {
this.$el.remove()
this.$destroy()
},
log () {
console.log('测试')
},
onClickClose () {
this.close()
if (this.closeButton && typeof this.closeButton.callback === 'function') {
this.closeButton.callback(this)//this === toast实例
}
}
}
}
</script>
<style scoped lang="scss">
$font-size: 14px;
$toast-min-height: 40px;
$toast-bg: rgba(0, 0, 0, 0.75);
.toast {
font-size: $font-size; min-height: $toast-min-height; line-height: 1.8;
position: fixed; top: 0; left: 50%; transform: translateX(-50%); display: flex;
color: white; align-items: center; background: $toast-bg; border-radius: 4px;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.50); padding: 0 16px;
.message {
padding: 8px 0;
}
.close {
padding-left: 16px;
flex-shrink: 0;
}
.line {
height: 100%;
border-left: 1px solid #666;
margin-left: 16px;
}
}
</style>

实现 position功能

src/app.js

image-20201217164002253

src/row.vue

image-20201217164034306

src/toast.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
<template>
<div class="toast" ref="wrapper" :class="toastClasses">
<div class="message">
<slot v-if="!enableHtml"></slot>
<div v-else v-html="$slots.default[0]"></div>
</div>
<div class="line" ref="line"></div>
<span class="close" v-if="closeButton" @click="onClickClose">
{{closeButton.text}}
</span>
</div>
</template>
<script>
export default {
name: 'WheelsToast',
props: {
autoClose: {
type: Boolean,
default: true
},
autoCloseDelay: {
type: Number,
default: 50
},
closeButton: {
type: Object,
default () {
return {
text: '关闭', callback: undefined
}
}
},
enableHtml: {
type: Boolean,
default: false
},
position: {
type: String,
default: 'top',
validator (value) {
return ['top', 'bottom', 'middle'].indexOf(value) >= 0
}
}
},
created () {
},
mounted () {
this.updateStyles()
this.execAutoClose()
},
computed: {
toastClasses () {
return {
[`position-${this.position}`]: true
}
}
},
methods: {
updateStyles () {
this.$nextTick(() => {
this.$refs.line.style.height =
`${this.$refs.wrapper.getBoundingClientRect().height}px`
})
},
execAutoClose () {
if (this.autoClose) {
setTimeout(() => {
this.close()
}, this.autoCloseDelay * 1000)
}
},
close () {
this.$el.remove()
this.$destroy()
},
log () {
console.log('测试')
},
onClickClose () {
this.close()
if (this.closeButton && typeof this.closeButton.callback === 'function') {
this.closeButton.callback(this)//this === toast实例
}
}
}
}
</script>
<style scoped lang="scss">
$font-size: 14px;
$toast-min-height: 40px;
$toast-bg: rgba(0, 0, 0, 0.75);
.toast {
font-size: $font-size;
min-height: $toast-min-height;
line-height: 1.8;
position: fixed;
left: 50%;
display: flex;
color: white;
align-items: center;
background: $toast-bg;
border-radius: 4px;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.50);
padding: 0 16px;
.message {
padding: 8px 0;
}
.close {
padding-left: 16px;
flex-shrink: 0;
}
.line {
height: 100%;
border-left: 1px solid #666;
margin-left: 16px;
}
&.position-top{
top: 0;
transform: translateX(-50%);
}
&.position-bottom{
bottom: 0;
transform: translateX(-50%);
}
&.position-middle{
top: 50%;
transform: translate(-50%, -50%);
}
}
</style>

实现只能有一个 toast 的功能

src/app.js

image-20201217180116498

src/plugin.js

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 Toast from './toast'

let currentToast

export default {
install (Vue, options) {
Vue.prototype.$toast = function (message, toastOptions) {
if (currentToast) {
currentToast.close()
}
currentToast = createToast({Vue, message, propsData: toastOptions})
}
}
}

/* helpers */
function createToast ({Vue, message, propsData}) {
let Constructor = Vue.extend(Toast)
let toast = new Constructor({propsData})
toast.$slots.default = [message]
toast.$mount()
document.body.appendChild(toast.$el)
return toast
}

实现进入动画

src/toast.vue

image-20201217181049208

解决 currentToast 的一个 bug

src/plugin.js

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 Toast from './toast'

let currentToast

export default {
install (Vue, options) {
Vue.prototype.$toast = function (message, toastOptions) {
if (currentToast) {
currentToast.close()
}
currentToast = createToast({
Vue,
message,
propsData: toastOptions,
onClose: () => {
currentToast = null
}
})
}
}
}

/* helpers */
function createToast ({Vue, message, propsData, onClose}) {
let Constructor = Vue.extend(Toast)
let toast = new Constructor({propsData})
toast.$slots.default = [message]
toast.$mount()
toast.$on('close', onClose)
document.body.appendChild(toast.$el)
return toast
}

src/toast.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
<template>
<div class="wrapper" :class="toastClasses">
<div class="toast" ref="toast">
<div class="message">
<slot v-if="!enableHtml"></slot>
<div v-else v-html="$slots.default[0]"></div>
</div>
<div class="line" ref="line"></div>
<span class="close" v-if="closeButton" @click="onClickClose">
{{closeButton.text}}
</span>
</div>
</div>
</template>
<script>
export default {
name: 'WheelsToast',
props: {
autoClose: {
type: Boolean,
default: true
},
autoCloseDelay: {
type: Number,
default: 50
},
closeButton: {
type: Object,
default () {
return {
text: '关闭', callback: undefined
}
}
},
enableHtml: {
type: Boolean,
default: false
},
position: {
type: String,
default: 'top',
validator (value) {
return ['top', 'bottom', 'middle'].indexOf(value) >= 0
}
}
},
created () {
},
mounted () {
this.updateStyles()
this.execAutoClose()
},
computed: {
toastClasses () {
return {
[`position-${this.position}`]: true
}
}
},
methods: {
updateStyles () {
this.$nextTick(() => {
this.$refs.line.style.height =
`${this.$refs.toast.getBoundingClientRect().height}px`
})
},
execAutoClose () {
if (this.autoClose) {
setTimeout(() => {
this.close()
}, this.autoCloseDelay * 1000)
}
},
close () {
this.$el.remove()
this.$emit('close')
this.$destroy()
},
log () {
console.log('测试')
},
onClickClose () {
this.close()
if (this.closeButton && typeof this.closeButton.callback === 'function') {
this.closeButton.callback(this)//this === toast实例
}
}
}
}
</script>
<style scoped lang="scss">
$font-size: 14px;
$toast-min-height: 40px;
$toast-bg: rgba(0, 0, 0, 0.75);
@keyframes fade-in {
0% {opacity: 0; transform: translateY(100%);}
100% {opacity: 1;transform: translateY(0%);}
}
.wrapper {
position: fixed;
left: 50%;
transform: translateX(-50%);
&.position-top{
top: 0;
}
&.position-bottom{
bottom: 0;
}
&.position-middle{
top: 50%;
transform: translate(-50%, -50%);
}
}

.toast {
animation: fade-in 1s;
font-size: $font-size;
min-height: $toast-min-height;
line-height: 1.8;
display: flex;
color: white;
align-items: center;
background: $toast-bg;
border-radius: 4px;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.50);
padding: 0 16px;
.message {
padding: 8px 0;
}
.close {
padding-left: 16px;
flex-shrink: 0;
}
.line {
height: 100%;
border-left: 1px solid #666;
margin-left: 16px;
}
}
</style>

实现三种动画

index.html

image-20201217190733119

src/app.js

image-20201217190834226

src/toast.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
<style scoped lang="scss">
$font-size: 14px;
$toast-min-height: 40px;
$toast-bg: rgba(0, 0, 0, 0.75);
@keyframes slide-up {
0% {opacity: 0; transform: translateY(100%);}
100% {opacity: 1;transform: translateY(0%);}
}
@keyframes slide-down {
0% {opacity: 0; transform: translateY(-100%);}
100% {opacity: 1;transform: translateY(0%);}
}
@keyframes fade-in {
0% {opacity: 0; }
100% {opacity: 1;}
}
.wrapper {
position: fixed;
left: 50%;
transform: translateX(-50%);
$animation-duration: 300ms;
&.position-top{
top: 0;
.toast {
border-top-left-radius: 0;
border-top-right-radius: 0;
animation: slide-down $animation-duration;
}
}
&.position-bottom{
bottom: 0;
.toast {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
animation: slide-up $animation-duration;
}
}
&.position-middle{
top: 50%;
transform: translate(-50%, -50%);
.toast {
animation: fade-in $animation-duration;
}
}
}

.toast {
font-size: $font-size;
min-height: $toast-min-height;
line-height: 1.8;
display: flex;
color: white;
align-items: center;
background: $toast-bg;
border-radius: 4px;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.50);
padding: 0 16px;
.message {
padding: 8px 0;
}
.close {
padding-left: 16px;
flex-shrink: 0;
}
.line {
height: 100%;
border-left: 1px solid #666;
margin-left: 16px;
}
}
</style>

完成toast测试用例

src/app.js

image-20201219180319827

src/toast.vue

image-20201219180520092

image-20201219180606004

test/toast.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Toast from '../src/toast'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Toast', () => {

it('存在.', () => {
expect(Toast).to.exist
})

describe('props', function () {
it('接受 autoClose', (done) => {
let div = document.createElement('div')
document.body.appendChild(div)
const Constructor = Vue.extend(Toast)
const vm = new Constructor({
propsData: {
autoClose: 1,
}
}).$mount(div)
vm.$on('close', () => {
expect(document.body.contains(vm.$el)).to.eq(false)
done()
})
})
it('接受 closeButton', (done) => {
const callback = sinon.fake();
const Constructor = Vue.extend(Toast)
const vm = new Constructor({
propsData: {
closeButton: {
text: '关闭吧',
callback,
},
}
}).$mount()
let closeButton = vm.$el.querySelector('.close')
expect(closeButton.textContent.trim()).to.eq('关闭吧')
setTimeout(()=>{
closeButton.click()
expect(callback).to.have.been.called
done()
},200)
})
it('接受 enableHtml', () => {
const Constructor = Vue.extend(Toast)
const vm = new Constructor({
propsData: {enableHtml: true}
})
vm.$slots.default = ['<strong id="test">hi</strong>']
vm.$mount()
let strong = vm.$el.querySelector('#test')
expect(strong).to.exist
})
it('接受 position', () => {
const Constructor = Vue.extend(Toast)
const vm = new Constructor({
propsData: {
position: 'bottom'
}
}).$mount()
expect(vm.$el.classList.contains('position-bottom')).to.eq(true)
})
})


})

Tab组件

添加tabs相关组件,添加props

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
html {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
body {
font-size: var(--font-size);
}
</style>
</head>
<body>
<div id="app">
<w-tabs :selected.sync="selectedTab" >
<w-tabs-head>
<template slot="actions">
<button>设置</button>
</template>
<w-tabs-item name="woman">
<w-icon name="settings"></w-icon>美女
</w-tabs-item>
<w-tabs-item name="finance" disabled>
财经
</w-tabs-item>
<w-tabs-item name="sports">
体育
</w-tabs-item>
</w-tabs-head>
<w-tabs-body>
<w-tabs-pane name="woman">
美女相关资讯
</w-tabs-pane>
<w-tabs-pane name="finance">
财经相关资讯
</w-tabs-pane>
<w-tabs-pane name="sports">
体育相关资讯
</w-tabs-pane>
</w-tabs-body>
</w-tabs>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/app.js

image-20201226163449882

src/tabs-body.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="tabs-body">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsBody'
}
</script>
<style>
.tabs-body {
}
</style>

src/tabs-head.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="tabs-head">
<slot></slot>
<slot name="actions"></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsHead'
}
</script>
<style>
.tabs-head {
}
</style>

src/tabs-item.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="tabs-item">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsItem',
props: {
disabled: {
type: Boolean,
default: false
}
}
}
</script>
<style>
.tabs-item {
}
</style>

src/tabs-pane.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="tabs-pane">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsPane'
}
</script>
<style>
.tabs-pane {
}
</style>

src/tabs.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
<template>
<div class="tabs">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabs',
props: {
selected: {
type: String,
required: true
},
direction: {
type: String,
default: 'horizontal',
validator (value) {
return ['horizontal', 'vertical'].indexOf(value) >= 0
}
}
},
created () {
// this.$emit('update:selected', 'xxx')
}
}
</script>
<style>
.tabs {
}
</style>

实现tabs的切换

index.html

image-20201226164924028

src/app.js

image-20201226164956205

src/tabs-body.vue

image-20201226165030884

src/tabs-head.vue

image-20201226165135550

src/tabs-item.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
<template>
<div class="tabs-item" @click="xxx" :class="classes">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsItem',
inject: ['eventBus'],
data () {
return {
active: false
}
},
props: {
disabled: {
type: Boolean,
default: false
},
name: {
type: String | Number,
required: true
}
},
computed: {
classes () {
return {
active: this.active
}
}
},
created () {
this.eventBus.$on('update:selected', (name) => {
this.active = name === this.name;
})
},
methods: {
xxx () {
this.eventBus.$emit('update:selected', this.name)
}
}
}
</script>
<style lang="scss" scoped>
.tabs-item {
flex-shrink: 0;
padding: 0 1em;
&.active {
background: red;
}
}
</style>

src/tabs-pane.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
<template>
<div class="tabs-pane" :class="classes" v-if="active">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsPane',
inject: ['eventBus'],
data () {
return {
active: false
}
},
props: {
name: {
type: String | Number,
required: true
}
},
computed: {
classes () {
return {
active: this.active
}
}
},
created () {
this.eventBus.$on('update:selected', (name) => {
this.active = name === this.name;
})
}
}
</script>
<style lang="scss" scoped>
.tabs-pane {
&.active {
background: red;
}
}
</style>

src/tabs.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
<template>
<div class="tabs">
<slot></slot>
</div>
</template>
<script>
import Vue from 'vue'
export default {
name: 'WheelsTabs',
props: {
selected: {
type: String,
required: true
},
direction: {
type: String,
default: 'horizontal',
validator (value) {
return ['horizontal', 'vertical'].indexOf(value) >= 0
}
}
},
data () {
return {
eventBus: new Vue()
}
},
provide () {
return {
eventBus: this.eventBus
}
},
mounted () {
// this.$emit('update:selected', '这是 this $emit 出来的数据')
this.eventBus.$emit('update:selected', this.selected)
// // this.$emit('update:selected', 'xxx')
}
}
</script>
<style>
.tabs {
}
</style>

增大tab item可点击区域

src/tabs-head.vue

image-20201226171045050

src/tabs-item.vue

image-20201226170909180

触发update:selected事件的时候添加一个item数据

src/tabs-head.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
<template>
<div class="tabs-head">
<slot></slot>
<div class="line" ref="line"></div>
<div class="actions-wrapper">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'WheelsTabsHead',
inject: ['eventBus'],
created () {
this.eventBus.$on('update:selected', (item, vm) => {
console.log(item)
})
}
}
</script>
<style scoped lang="scss">
$blue: blue;
$tab-height: 40px;
.tabs-head {
display: flex;
height: $tab-height;
justify-content: flex-start;
border: 1px solid red;
position: relative;
> .line {
position: absolute;
bottom: 0;
border-bottom: 1px solid $blue;
width: 100px;
}
> .actions-wrapper {
margin-left: auto;
}
}
</style>

src/tabs-item.vue

image-20201226171827021

src/tabs.vue

image-20201226184206225

tab切换动画完成

src/tabs-head.vue

image-20201226172301095

完善tabs的样式

src/tabs-head.vue

image-20201226172618628

src/tabs-pane.vue

image-20201226172640819

支持disabled功能

src/tabs-item.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
<template>
<div class="tabs-item" @click="onClick" :class="classes" :data-name="name">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'WheelsTabsItem',
inject: ['eventBus'],
data () {
return {
active: false
}
},
props: {
disabled: {
type: Boolean,
default: false
},
name: {
type: String | Number,
required: true
}
},
computed: {
classes () {
return {
active: this.active,
disabled: this.disabled
}
}
},
created () {
if (this.eventBus) {
this.eventBus.$on('update:selected', (name) => {
this.active = name === this.name;
})
}
},
methods: {
onClick () {
if (this.disabled) { return }
this.eventBus && this.eventBus.$emit('update:selected', this.name, this)
this.$emit('click', this)
}
}
}
</script>
<style lang="scss" scoped>
$blue: blue;
$disabled-text-color: grey;
.tabs-item {
flex-shrink: 0;
padding: 0 1em;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
&.active {
color: $blue;
font-weight: bold;
}
&.disabled {
color: $disabled-text-color;
cursor: not-allowed;
}
}
</style>

src/tabs.vue

image-20201226184347654

添加部分测试用例

test/tabs-item.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Tabs from '../src/tabs'
import TabsHead from '../src/tabs-head'
import TabsBody from '../src/tabs-body'
import TabsItem from '../src/tabs-item'
import TabsPane from '../src/tabs-pane'

Vue.component('w-tabs', Tabs)
Vue.component('w-tabs-head', TabsHead)
Vue.component('w-tabs-body', TabsBody)
Vue.component('w-tabs-item', TabsItem)
Vue.component('w-tabs-pane', TabsPane)

Vue.config.productionTip = false
Vue.config.devtools = false

describe('TabsItem', () => {

it('存在.', () => {
expect(TabsItem).to.exist
})

it('接受 name 属性', () => {
const Constructor = Vue.extend(TabsItem)
const vm = new Constructor({
propsData: {
name: 'xxx',
}
}).$mount()
expect(vm.$el.getAttribute('data-name')).to.eq('xxx')
})
it('接受 disabled 属性', () => {
const Constructor = Vue.extend(TabsItem)
const vm = new Constructor({
propsData: {
disabled: true,
}
}).$mount()
expect(vm.$el.classList.contains('disabled')).to.be.true
const callback = sinon.fake();
vm.$on('click', callback)
vm.$el.click()
expect(callback).to.have.not.been.called
})
})

test/tabs.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Tabs from '../src/tabs'
import TabsHead from '../src/tabs-head'
import TabsBody from '../src/tabs-body'
import TabsItem from '../src/tabs-item'
import TabsPane from '../src/tabs-pane'

Vue.component('w-tabs', Tabs)
Vue.component('w-tabs-head', TabsHead)
Vue.component('w-tabs-body', TabsBody)
Vue.component('w-tabs-item', TabsItem)
Vue.component('w-tabs-pane', TabsPane)

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Tabs', () => {

it('存在.', () => {
expect(Tabs).to.exist
})

it('接受 selected 属性', (done) => {

const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<g-tabs selected="finance">
<g-tabs-head>
<g-tabs-item name="woman"> 美女 </g-tabs-item>
<g-tabs-item name="finance"> 财经 </g-tabs-item>
<g-tabs-item name="sports"> 体育 </g-tabs-item>
</g-tabs-head>
<g-tabs-body>
<g-tabs-pane name="woman"> 美女相关资讯 </g-tabs-pane>
<g-tabs-pane name="finance"> 财经相关资讯 </g-tabs-pane>
<g-tabs-pane name="sports"> 体育相关资讯 </g-tabs-pane>
</g-tabs-body>
</g-tabs>
`
let vm = new Vue({
el: div
})
vm.$nextTick(() => {
let x = vm.$el.querySelector(`.tabs-item[data-name="finance"]`)
expect(x.classList.contains('active')).to.be.true
done()
})
})

it('可以接受 direction prop', () => {

})

})

重构tabs.vue

src/tabs.vue

image-20201226185203845

Popover组件

创建popover组件

index.html

image-20201226190128311

src/app.js

image-20201226185949495

src/popover.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
<template>
<div class="popover" @click="xxx">
<div class="content-wrapper" v-if="visible">
<slot name="content"></slot>
</div>
<slot></slot>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {visible: false}
},
methods: {
xxx () {
this.visible = !this.visible
}
}
}
</script>

<style scoped lang="scss">
.popover {
display: inline-block;
vertical-align: top;
position: relative;
.content-wrapper {
position: absolute;
bottom: 100%;
left: 0;
border: 1px solid red;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
}
</style>

实现简单的popover

index.html

image-20201226190541364

src/popover.vue

image-20201226190632634

解决overflow:hidden的bug

index.html

image-20201226191627938

src/app.js

image-20201226191741208

src/popover.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
<template>
<div class="popover" @click.stop="xxx">
<div ref="contentWrapper" class="content-wrapper"
v-if="visible">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {visible: false}
},
methods: {
xxx () {
this.visible = !this.visible
if (this.visible === true) {
this.$nextTick(() => {
document.body.appendChild(this.$refs.contentWrapper)
let {width, height, top, left} = this.$refs.triggerWrapper.getBoundingClientRect()
this.$refs.contentWrapper.style.left = left + window.scrollX + 'px'
this.$refs.contentWrapper.style.top = top + window.scrollY + 'px'
let eventHandler = () => {
this.visible = false
document.removeEventListener('click', eventHandler)
}
document.addEventListener('click', eventHandler)
})
}else{
console.log('vm 隐藏 popover')
}
}
}
}
</script>

<style scoped lang="scss">
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid red;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
transform: translateY(-100%);
}
</style>

功能方面基本完成

src/popover.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
<template>
<div class="popover" @click="onClick" ref="popover">
<div ref="contentWrapper" class="content-wrapper" v-if="visible">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {visible: false}
},
methods: {
positionContent () {
document.body.appendChild(this.$refs.contentWrapper)
let {width, height, top, left} = this.$refs.triggerWrapper.getBoundingClientRect()
this.$refs.contentWrapper.style.left = left + window.scrollX + 'px'
this.$refs.contentWrapper.style.top = top + window.scrollY + 'px'
},
onClickDocument (e) {
if (this.$refs.popover &&
(this.$refs.popover === e.target || this.$refs.popover.contains(e.target))
) { return }
this.close()
},
open () {
this.visible = true
this.$nextTick(() => {
this.positionContent()
document.addEventListener('click', this.onClickDocument)
})
},
close () {
this.visible = false
document.removeEventListener('click', this.onClickDocument)
},
onClick (event) {
if (this.$refs.triggerWrapper.contains(event.target)) {
if (this.visible === true) {
this.close()
} else {
this.open()
}
}
}
}
}
</script>

<style scoped lang="scss">
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid red;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
transform: translateY(-100%);
}
</style>

添加默认样式

index.html

image-20201226192723973

src/popover.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
<template>
<div class="popover" @click="onClick" ref="popover">
<div ref="contentWrapper" class="content-wrapper" v-if="visible">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper" style="display: inline-block;">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {visible: false}
},
methods: {
positionContent () {
document.body.appendChild(this.$refs.contentWrapper)
let {width, height, top, left} = this.$refs.triggerWrapper.getBoundingClientRect()
this.$refs.contentWrapper.style.left = left + window.scrollX + 'px'
this.$refs.contentWrapper.style.top = top + window.scrollY + 'px'
},
onClickDocument (e) {
if (this.$refs.popover &&
(this.$refs.popover === e.target || this.$refs.popover.contains(e.target))
) { return }
if (this.$refs.contentWrapper &&
(this.$refs.contentWrapper === e.target || this.$refs.contentWrapper.contains(e.target))
) { return }
this.close()
},
open () {
this.visible = true
this.$nextTick(() => {
this.positionContent()
document.addEventListener('click', this.onClickDocument)
})
},
close () {
this.visible = false
document.removeEventListener('click', this.onClickDocument)
},
onClick (event) {
if (this.$refs.triggerWrapper.contains(event.target)) {
if (this.visible === true) {
this.close()
console.log('click close')
} else {
this.open()
}
}
}
}
}
</script>

<style scoped lang="scss">
$border-color: #333;
$border-radius: 4px;
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid $border-color;
border-radius: $border-radius;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
background: white;
transform: translateY(-100%);
margin-top: -10px;
padding: .5em 1em;
max-width: 20em;
word-break: break-all;
&::before, &::after {
content: '';
display: block;
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
left: 10px;
}
&::before {
border-top-color: black;
top: 100%;
}
&::after {
border-top-color: white;
top: calc(100% - 1px);
}
}
</style>

四个方向的位置

index.html

image-20201226193419947

src/popover.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
<template>
<div class="popover" @click="onClick" ref="popover">
<div ref="contentWrapper" class="content-wrapper" v-if="visible" :class="{[`position-${position}`]:true}">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper" style="display: inline-block;">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {visible: false}
},
props: {
position: {
type: String,
default: 'top',
validator (value) {
return ['top', 'bottom', 'left', 'right'].indexOf(value) >= 0
}
}
},
methods: {
positionContent () {
const {contentWrapper, triggerWrapper} = this.$refs
document.body.appendChild(contentWrapper)
let {width, height, top, left} = triggerWrapper.getBoundingClientRect()
if (this.position === 'top') {
contentWrapper.style.left = left + window.scrollX + 'px'
contentWrapper.style.top = top + window.scrollY + 'px'
} else if (this.position === 'bottom') {
contentWrapper.style.left = left + window.scrollX + 'px'
contentWrapper.style.top = top + height + window.scrollY + 'px'
} else if (this.position === 'left') {
contentWrapper.style.left = left + window.scrollX + 'px'
let {height: height2} = contentWrapper.getBoundingClientRect()
contentWrapper.style.top = top + window.scrollY +
(height - height2) / 2 + 'px'
} else if (this.position === 'right') {
contentWrapper.style.left = left + window.scrollX + width + 'px'
let {height: height2} = contentWrapper.getBoundingClientRect()
contentWrapper.style.top = top + window.scrollY +
(height - height2) / 2 + 'px'
}
},
onClickDocument (e) {
if (this.$refs.popover &&
(this.$refs.popover === e.target || this.$refs.popover.contains(e.target))
) { return }
if (this.$refs.contentWrapper &&
(this.$refs.contentWrapper === e.target || this.$refs.contentWrapper.contains(e.target))
) { return }
this.close()
},
open () {
this.visible = true
this.$nextTick(() => {
this.positionContent()
document.addEventListener('click', this.onClickDocument)
})
},
close () {
this.visible = false
document.removeEventListener('click', this.onClickDocument)
},
onClick (event) {
if (this.$refs.triggerWrapper.contains(event.target)) {
if (this.visible === true) {
this.close()
console.log('click close')
} else {
this.open()
}
}
}
}
}
</script>

<style scoped lang="scss">
$border-color: #333;
$border-radius: 4px;
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid $border-color;
border-radius: $border-radius;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
background: white;
padding: .5em 1em;
max-width: 20em;
word-break: break-all;
&::before, &::after {
content: '';
display: block;
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
}
&.position-top {
transform: translateY(-100%);
margin-top: -10px;
&::before, &::after {
left: 10px;
}
&::before {
border-top-color: black;
top: 100%;
}
&::after {
border-top-color: white;
top: calc(100% - 1px);
}
}
&.position-bottom {
margin-top: 10px;
&::before, &::after {
left: 10px;
}
&::before {
border-bottom-color: black;
bottom: 100%;
}
&::after {
border-bottom-color: white;
bottom: calc(100% - 1px);
}
}
&.position-left {
transform: translateX(-100%);
margin-left: -10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-left-color: black;
left: 100%;
}
&::after {
border-left-color: white;
left: calc(100% - 1px);
}
}
&.position-right {
margin-left: 10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-right-color: black;
right: 100%;
}
&::after {
border-right-color: white;
right: calc(100% - 1px);
}
}
}
</style>

使用表驱动编程重构代码

src/popover.vue

image-20201226193946505

支持click和hover两种方式

index.html

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
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wheels</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
html {
--button-height: 32px;
--font-size: 14px;
--button-bg: white;
--button-active-bg: #eee;
--border-radius: 4px;
--color: #333;
--border-color: #999;
--border-color-hover: #666;
}
body {
font-size: var(--font-size);
}
</style>
</head>
<body>
<div id="app" style="padding-left: 100px;">
<div style="overflow: hidden; padding-top: 150px; padding-bottom: 40px;">
<w-popover position="bottom">
<template slot="content">
<div>askdjsakdjsakjdslkjdlskjdlsjkdlskjdlsjkdlskjdlsajkdlajkdlkajdlksajdladjk</div>
<div>askdjsakdjsakjdslkjdlskjdlsjkdlskjdlsjkdlskjdlsajkdlajkdlkajdlksajdladjk</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="top">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="left">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="right">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
</div>
<div style="overflow: hidden; padding-bottom: 150px;">
<w-popover position="bottom" trigger="hover">
<template slot="content">
<div>askdjsakdjsakjdslkjdlskjdlsjkdlskjdlsjkdlskjdlsajkdlajkdlkajdlksajdladjk</div>
<div>askdjsakdjsakjdslkjdlskjdlsjkdlskjdlsjkdlskjdlsajkdlajkdlkajdlksajdladjk</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="top" trigger="hover">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="left" trigger="hover">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
<w-popover position="right" trigger="hover">
<template slot="content">
<div>popover内容</div>
</template>
<w-button>点我</w-button>
</w-popover>
</div>
</div>
<script src="./src/app.js"></script>
</body>
</html>

src/popover.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
<template>
<div class="popover" ref="popover">
<div ref="contentWrapper" class="content-wrapper" v-if="visible" :class="{[`position-${position}`]:true}">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper" style="display: inline-block;">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {
visible: false,
}
},
mounted () {
if (this.trigger === 'click') {
this.$refs.popover.addEventListener('click', this.onClick)
} else {
this.$refs.popover.addEventListener('mouseenter', this.open)
this.$refs.popover.addEventListener('mouseleave', this.close)
}
},
destroyed () {
if (this.trigger === 'click') {
this.$refs.popover.removeEventListener('click', this.onClick)
} else {
this.$refs.popover.removeEventListener('mouseenter', this.open)
this.$refs.popover.removeEventListener('mouseleave', this.close)
}
},
computed: {
openEvent () {
if (this.trigger === 'click') {
return 'click'
} else {
return 'mouseenter'
}
},
closeEvent () {
if (this.trigger === 'click') {
return 'click'
} else {
return 'mouseleave'
}
}
},
props: {
position: {
type: String,
default: 'top',
validator (value) {
return ['top', 'bottom', 'left', 'right'].indexOf(value) >= 0
}
},
trigger: {
type: String,
default: 'click',
validator (value) {
return ['click', 'hover'].indexOf(value) >= 0
}
}
},
methods: {
positionContent () {
const {contentWrapper, triggerWrapper} = this.$refs
document.body.appendChild(contentWrapper)
const {width, height, top, left} = triggerWrapper.getBoundingClientRect()
const {height: height2} = contentWrapper.getBoundingClientRect()
let positions = {
top: {top: top + window.scrollY, left: left + window.scrollX,},
bottom: {top: top + height + window.scrollY, left: left + window.scrollX},
left: {
top: top + window.scrollY + (height - height2) / 2,
left: left + window.scrollX
},
right: {
top: top + window.scrollY + (height - height2) / 2,
left: left + window.scrollX + width
},
}
contentWrapper.style.left = positions[this.position].left + 'px'
contentWrapper.style.top = positions[this.position].top + 'px'
},
onClickDocument (e) {
if (this.$refs.popover &&
(this.$refs.popover === e.target || this.$refs.popover.contains(e.target))
) { return }
if (this.$refs.contentWrapper &&
(this.$refs.contentWrapper === e.target || this.$refs.contentWrapper.contains(e.target))
) { return }
this.close()
},
open () {
this.visible = true
this.$nextTick(() => {
this.positionContent()
document.addEventListener('click', this.onClickDocument)
})
},
close () {
this.visible = false
document.removeEventListener('click', this.onClickDocument)
},
onClick (event) {
if (this.$refs.triggerWrapper.contains(event.target)) {
if (this.visible === true) {
this.close()
console.log('click close')
} else {
this.open()
}
}
}
}
}
</script>

<style scoped lang="scss">
$border-color: #333;
$border-radius: 4px;
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid $border-color;
border-radius: $border-radius;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
background: white;
padding: .5em 1em;
max-width: 20em;
word-break: break-all;
&::before, &::after {
content: '';
display: block;
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
}
&.position-top {
transform: translateY(-100%);
margin-top: -10px;
&::before, &::after {
left: 10px;
}
&::before {
border-top-color: black;
top: 100%;
}
&::after {
border-top-color: white;
top: calc(100% - 1px);
}
}
&.position-bottom {
margin-top: 10px;
&::before, &::after {
left: 10px;
}
&::before {
border-bottom-color: black;
bottom: 100%;
}
&::after {
border-bottom-color: white;
bottom: calc(100% - 1px);
}
}
&.position-left {
transform: translateX(-100%);
margin-left: -10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-left-color: black;
left: 100%;
}
&::after {
border-left-color: white;
left: calc(100% - 1px);
}
}
&.position-right {
margin-left: 10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-right-color: black;
right: 100%;
}
&::after {
border-right-color: white;
right: calc(100% - 1px);
}
}
}
</style>

解决抖动bug

src/popover.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
<template>
<div class="popover" ref="popover">
<div ref="contentWrapper" class="content-wrapper" v-if="visible" :class="{[`position-${position}`]:true}">
<slot name="content"></slot>
</div>
<span ref="triggerWrapper" style="display: inline-block;">
<slot></slot>
</span>
</div>
</template>

<script>
export default {
name: "WheelsPopover",
data () {
return {
visible: false,
}
},
mounted () {
if (this.trigger === 'click') {
this.$refs.popover.addEventListener('click', this.onClick)
} else {
this.$refs.popover.addEventListener('mouseenter', this.open)
this.$refs.popover.addEventListener('mouseleave', this.close)
}
},
destroyed () {
if (this.trigger === 'click') {
this.$refs.popover.removeEventListener('click', this.onClick)
} else {
this.$refs.popover.removeEventListener('mouseenter', this.open)
this.$refs.popover.removeEventListener('mouseleave', this.close)
}
},
computed: {
openEvent () {
if (this.trigger === 'click') {
return 'click'
} else {
return 'mouseenter'
}
},
closeEvent () {
if (this.trigger === 'click') {
return 'click'
} else {
return 'mouseleave'
}
}
},
props: {
position: {
type: String,
default: 'top',
validator (value) {
return ['top', 'bottom', 'left', 'right'].indexOf(value) >= 0
}
},
trigger: {
type: String,
default: 'click',
validator (value) {
return ['click', 'hover'].indexOf(value) >= 0
}
}
},
methods: {
positionContent () {
const {contentWrapper, triggerWrapper} = this.$refs
document.body.appendChild(contentWrapper)
const {width, height, top, left} = triggerWrapper.getBoundingClientRect()
const {height: height2} = contentWrapper.getBoundingClientRect()
let positions = {
top: {top: top + window.scrollY, left: left + window.scrollX,},
bottom: {top: top + height + window.scrollY, left: left + window.scrollX},
left: {
top: top + window.scrollY + (height - height2) / 2,
left: left + window.scrollX
},
right: {
top: top + window.scrollY + (height - height2) / 2,
left: left + window.scrollX + width
},
}
contentWrapper.style.left = positions[this.position].left + 'px'
contentWrapper.style.top = positions[this.position].top + 'px'
},
onClickDocument (e) {
if (this.$refs.popover &&
(this.$refs.popover === e.target || this.$refs.popover.contains(e.target))
) { return }
if (this.$refs.contentWrapper &&
(this.$refs.contentWrapper === e.target || this.$refs.contentWrapper.contains(e.target))
) { return }
this.close()
},
open () {
this.visible = true
this.$nextTick(() => {
this.positionContent()
document.addEventListener('click', this.onClickDocument)
})
},
close () {
this.visible = false
document.removeEventListener('click', this.onClickDocument)
},
onClick (event) {
if (this.$refs.triggerWrapper.contains(event.target)) {
if (this.visible === true) {
this.close()
console.log('click close')
} else {
this.open()
}
}
}
}
}
</script>

<style scoped lang="scss">
$border-color: #333;
$border-radius: 4px;
.popover {
display: inline-block;
vertical-align: top;
position: relative;
}
.content-wrapper {
position: absolute;
border: 1px solid $border-color;
border-radius: $border-radius;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
background: white;
padding: .5em 1em;
max-width: 20em;
word-break: break-all;
&::before, &::after {
content: '';
display: block;
border: 10px solid transparent;
width: 0;
height: 0;
position: absolute;
}
&.position-top {
transform: translateY(-100%);
margin-top: -10px;
&::before, &::after {
left: 10px;
}
&::before {
border-top-color: black;
border-bottom: none;
top: 100%;
}
&::after {
border-top-color: white;
border-bottom: none;
top: calc(100% - 1px);
}
}
&.position-bottom {
margin-top: 10px;
&::before, &::after {
left: 10px;
}
&::before {
border-top: none;
border-bottom-color: black;
bottom: 100%;
}
&::after {
border-top: none;
border-bottom-color: white;
bottom: calc(100% - 1px);
}
}
&.position-left {
transform: translateX(-100%);
margin-left: -10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-left-color: black;
border-right: none;
left: 100%;
}
&::after {
border-left-color: white;
border-right: none;
left: calc(100% - 1px);
}
}
&.position-right {
margin-left: 10px;
&::before, &::after {
transform: translateY(-50%);
top: 50%;
}
&::before {
border-right-color: black;
border-left: none;
right: 100%;
}
&::after {
border-right-color: white;
border-left: none;
right: calc(100% - 1px);
}
}
}
</style>

添加插槽close API

index.html

image-20201227114610479

src/popover.vue

image-20201227114646792

提交popover测试用例

test/popover.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Popover from '../src/popover'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Popover', () => {

it('存在.', () => {
expect(Popover).to.exist
})

it('可以设置position.', (done) => {
Vue.component('g-popover', Popover)
const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<g-popover position="bottom" ref="a">
<template slot="content">
弹出内容
</template>
<button>点我</button>
</g-popover>
`
const vm = new Vue({
el: div
})
vm.$el.querySelector('button').click()
vm.$nextTick(() => {
const {contentWrapper} = vm.$refs.a.$refs
expect(contentWrapper.classList.contains('position-bottom')).to.be.true
done()
})
})
xit('可以设置 trigger', (done) => {
Vue.component('g-popover', Popover)
const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<g-popover trigger="hover" ref="a">
<template slot="content">
弹出内容
</template>
<button>点我</button>
</g-popover>
`
const vm = new Vue({
el: div
})
setTimeout(() => {
let event = new Event('mouseenter');
vm.$el.dispatchEvent(event)
vm.$nextTick(() => {
const {contentWrapper} = vm.$refs.a.$refs
expect(contentWrapper).to.exist
done()
})
}, 200)

})

})

手风琴组件

collapse基本样式/功能完成

index.html

image-20201227202233849

src/app.js

image-20201227202336241

src/collapse-item.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
<template>
<div class="collapseItem">
<div class="title" @click="open=!open">
{{title}}
</div>
<div class="content" v-if="open">
<slot></slot>
</div>
</div>
</template>

<script>
export default {
name: "WheelsCollapseItem",
props: {
title: {
type: String,
required: true
}
},
data () {
return {
open: false
}
}
}
</script>

<style scoped lang="scss">
$grey: #ddd;
$border-radius: 4px;
.collapseItem {
> .title {
border: 1px solid $grey;
margin-top: -1px;
margin-left: -1px;
margin-right: -1px;
min-height: 32px;
display: flex;
align-items: center;
padding: 0 8px;
}
&:first-child {
> .title {
border-top-left-radius: $border-radius;
border-top-right-radius: $border-radius;
}
}
&:last-child {
> .title:last-child {
border-bottom-left-radius: $border-radius;
border-bottom-right-radius: $border-radius;
}
}
> .content {
padding: 8px;
}
}
</style>

src/collapse.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="collapse">
<slot></slot>
</div>
</template>

<script>
export default {
name: "WheelsCollapse"
}
</script>

<style scoped lang="scss">
$grey: #ddd;
$border-radius: 4px;
.collapse {
border: 1px solid $grey;
border-radius: $border-radius;
}
</style>

添加single选项

src/collapse-item.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
<template>
<div class="collapseItem">
<div class="title" @click="toggle">
{{title}}
</div>
<div class="content" v-if="open">
<slot></slot>
</div>
</div>
</template>

<script>
export default {
name: "WheelsCollapseItem",
props: {
title: {
type: String,
required: true
}
},
data () {
return {
open: false
}
},
inject: ['eventBus'],
mounted () {
this.eventBus && this.eventBus.$on('update:selected', (vm) => {
if (vm !== this) {
this.close()
}
})
},
methods: {
toggle () {
if (this.open) {
this.open = false
} else {
this.open = true
this.eventBus && this.eventBus.$emit('update:selected', this)
}
},
close () {
this.open = false
}
},
}
</script>

<style scoped lang="scss">
$grey: #ddd;
$border-radius: 4px;
.collapseItem {
> .title {
border: 1px solid $grey;
margin-top: -1px;
margin-left: -1px;
margin-right: -1px;
min-height: 32px;
display: flex;
align-items: center;
padding: 0 8px;
}
&:first-child {
> .title {
border-top-left-radius: $border-radius; border-top-right-radius: $border-radius;
}
}
&:last-child {
> .title:last-child {
border-bottom-left-radius: $border-radius; border-bottom-right-radius: $border-radius;
}
}
> .content {
padding: 8px;
}
}
</style>

src/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
<template>
<div class="collapse">
<slot></slot>
</div>
</template>

<script>
import Vue from 'vue'
export default {
name: "WheelsCollapse",
props: {
single: {
type: Boolean,
default: false
}
},
data () {
return {
eventBus: new Vue()
}
},
provide () {
if (this.single) {
return {
eventBus: this.eventBus
}
}
}
}
</script>

<style scoped lang="scss">
$grey: #ddd;
$border-radius: 4px;
.collapse {
border: 1px solid $grey;
border-radius: $border-radius;
}
</style>

报错

1
2
3
4
5
6
7
vue.common.dev.js:750 [Vue warn]: Injection "eventBus" not found

found in

---> <WheelsCollapseItem>
<WheelsCollapse>
<Root>

image-20201227204441103

设置默认selected

index.html

image-20201227205512126

src/app.js

image-20201227205539928

src/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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<template>
<div class="collapseItem">
<div class="title" @click="toggle">
{{title}}
</div>
<div class="content" v-if="open">
<slot></slot>
</div>
</div>
</template>

<script>
export default {
name: "WheelsCollapseItem",
props: {
title: {
type: String,
required: true
},
name: {
type: String,
required: true
}
},
data () {
return {
open: false
}
},
inject: ['eventBus'],
mounted () {
this.eventBus && this.eventBus.$on('update:selected', (name) => {
if (name !== this.name) {
this.close()
} else {
this.show()
}
})
},
methods: {
toggle () {
if (this.open) {
this.open = false
} else {
this.eventBus && this.eventBus.$emit('update:selected', this.name)
}
},
close () {
this.open = false
},
show () {
this.open = true
}
},
}
</script>

<style scoped lang="scss">
$grey: #ddd;
$border-radius: 4px;
.collapseItem {
> .title {
border: 1px solid $grey;
margin-top: -1px;
margin-left: -1px;
margin-right: -1px;
min-height: 32px;
display: flex;
align-items: center;
padding: 0 8px;
}
&:first-child {
> .title {
border-top-left-radius: $border-radius; border-top-right-radius: $border-radius;
}
}
&:last-child {
> .title:last-child {
border-bottom-left-radius: $border-radius; border-bottom-right-radius: $border-radius;
}
}
> .content {
padding: 8px;
}
}
</style>

src/collapse.vue

image-20201227205705979

将selected改为数组

index.html

image-20201227210212666

src/app.js

image-20201227210232479

src/collapse-item.vue

image-20201227210418072

src/collapse.vue

image-20201227210525503

更新demo

index.html

image-20201227210823032

src/collapse-item.vue

image-20201227210949409

测试collapse的属性和事件

src/collapse-item.vue

image-20201227211249229

test/collapse.test.js

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
const expect = chai.expect;
import Vue from 'vue'
import Collapse from '../src/collapse'
import CollapseItem from '../src/collapse-item'

Vue.config.productionTip = false
Vue.config.devtools = false

describe('Collapse', () => {

it('存在.', () => {
expect(Collapse).to.exist
})

it('接受 selected 属性', (done) => {

Vue.component('w-collapse', Collapse)
Vue.component('w-collapse-item', CollapseItem)
const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<w-collapse :selected="xxx">
<w-collapse-item title="标题1" name="1"><span id="content-1">1</span></w-collapse-item>
<w-collapse-item title="标题2" name="2"><span id="content-2">2</span></w-collapse-item>
<w-collapse-item title="标题3" name="3"><span id="content-3">3</span></w-collapse-item>
</w-collapse>
`
const vm = new Vue({
el: div,
data: {
xxx: ['1', '2']
}
})
setTimeout(() => {
expect(vm.$el.querySelector('#content-1')).to.exist
expect(vm.$el.querySelector('#content-2')).to.exist
expect(vm.$el.querySelector('#content-3')).to.not.exist
done()
})
})

it('接受 single 属性', (done) => {

Vue.component('w-collapse', Collapse)
Vue.component('w-collapse-item', CollapseItem)
const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<w-collapse :selected.sync="xxx" single>
<w-collapse-item title="标题1" name="1"><span id="content-1">1</span></w-collapse-item>
<w-collapse-item title="标题2" name="2"><span id="content-2">2</span></w-collapse-item>
<w-collapse-item title="标题3" name="3"><span id="content-3">3</span></w-collapse-item>
</w-collapse>
`
const vm = new Vue({
el: div,
data: {
xxx: ['1']
}
})
setTimeout(() => {
vm.$el.querySelector('[data-name="2"]').click()
setTimeout(() => {
expect(vm.$el.querySelector('#content-1')).to.not.exist
expect(vm.$el.querySelector('#content-2')).to.exist
done()
})
})
})

it('触发 update:selected 事件', (done) => {
Vue.component('w-collapse', Collapse)
Vue.component('w-collapse-item', CollapseItem)
const div = document.createElement('div')
document.body.appendChild(div)
div.innerHTML = `
<w-collapse :selected="xxx" @update:selected="onSelect">
<w-collapse-item title="标题1" name="1"><span id="content-1">1</span></w-collapse-item>
<w-collapse-item title="标题2" name="2"><span id="content-2">2</span></w-collapse-item>
<w-collapse-item title="标题3" name="3"><span id="content-3">3</span></w-collapse-item>
</w-collapse>
`
const callback = sinon.fake();
const vm = new Vue({
el: div,
data: {
xxx: ['1']
},
methods: {
onSelect: callback
}
})
setTimeout(() => {
vm.$el.querySelector('[data-name="2"]').click()
setTimeout(() => {
expect(callback).to.have.been.called
done()
})
})
})
})