框架搭建 项目初始化 请使用git、Github和npm初始化一个仓库,要求:
在Github上有一个远程仓库
仓库在有一个package.json
仓库在有一个LICENSE文件
仓库在有一个README.md文件
仓库在有一个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 >
1 2 3 4 5 Vue.component('w-button' , { template: ` <button class="w-button">按钮</button> ` })
请使用parcel
来启动你的应用,要求:
安装parcel-bundler
创建src/app.js
文件
创建src/button.vue
文件
在index.html
中引用src/app.js
文件
在src/app.js
中引用vue
和src/button.vue
让<w-button></w-button>
变成<button>按钮</button>
访问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' , })
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.j s: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属性
验证iconPosition为left或right中的一个
将svg代码整理到icon.vue
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状态
添加click事件
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>
第一个元素不需要margin-left: -1px;
单元测试与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() } { 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
使用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({ basePath: '' , frameworks: ['mocha' , 'sinon-chai' ], client: { chai: { includeStack: true } }, files: ['dist/**/*.test.js' , 'dist/**/*.test.css' ], exclude: [], preprocessors: {}, reporters: ['progress' ], port: 9876 , colors: true , logLevel: config.LOG_INFO, autoWatch: true , browsers: ['ChromeHeadless' ], singleRun: false , concurrency: Infinity }); };
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.json
的main
改为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
链接里面的内容粘贴。
简化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" },
文本输入框
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 >
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
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
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) 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' ) }) }) }) })
index.html
app.js
网格系统 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
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
src/row.vue
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
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
src/col.vue
添加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: 577 px) { $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: 769 px) { $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: 993 px) { $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: 1201 px) { $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/layout.vue src/sider.vue 基本功能完成 index.html
src/app.js
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>
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
src/layout.vue 删掉这行代码
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
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
src/plugin.js
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
src/row.vue
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
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 currentToastexport default { install (Vue, options) { Vue.prototype.$toast = function (message, toastOptions ) { if (currentToast) { currentToast.close() } currentToast = createToast({Vue, message, propsData : toastOptions}) } } } 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
解决 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 currentToastexport default { install (Vue, options) { Vue.prototype.$toast = function (message, toastOptions ) { if (currentToast) { currentToast.close() } currentToast = createToast({ Vue, message, propsData: toastOptions, onClose: () => { currentToast = null } }) } } } 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
src/app.js
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
src/toast.vue
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
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
src/app.js
src/tabs-body.vue
src/tabs-head.vue
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
src/tabs-item.vue
触发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
src/tabs.vue
tab切换动画完成 src/tabs-head.vue
完善tabs的样式 src/tabs-head.vue
src/tabs-pane.vue
支持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
添加部分测试用例 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
Popover组件 创建popover组件 index.html
src/app.js
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
src/popover.vue
解决overflow:hidden的bug index.html
src/app.js
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
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
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
支持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
src/popover.vue
提交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
src/app.js
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 >
设置默认selected index.html
src/app.js
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
将selected改为数组 index.html
src/app.js
src/collapse-item.vue
src/collapse.vue
更新demo index.html
src/collapse-item.vue
测试collapse的属性和事件 src/collapse-item.vue
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() }) }) }) })